Medium chrome Authorization Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactConfused deputy in Google Lens
DescriptionConfused deputy in Google Lens
ComponentGoogle Lens
Bug ClassAuthorization Error
Tracker517689673
Fix commit259ed9d14bd8 (chromium/src) +606/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
modified
switch
chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
modified
for
chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
  • chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
From 259ed9d14bd89ed5eb1357fb87d907c4e732e5f0 Mon Sep 17 00:00:00 2001
From: Juan Mojica <[email protected]>
Date: Tue, 15 Sep 2026 17:21:45 -0700
Subject: [PATCH] Validate navigation parameters in Lens overlay side panel

LensOverlaySidePanelWebView::OpenURLFromTab forwarded incoming
OpenURLParams directly to BrowserWindowInterface::OpenURL. That path
supplies a null source WebContents, so the popup blocker never ran for
navigations requested from the side panel, and the unvalidated
parameters could cause mismatched FrameTreeNode lookups when a subframe
requested a navigation targeting another frame tree.

Validate requests that originate from the results frame, which renders
remotely served content:

- Restrict forwarded navigations to HTTP and HTTPS schemes.
- Confirm a claimed user gesture against transient activation in the
  initiating RenderFrameHost, ignoring any initiator that does not
  belong to this side panel.
- Demote dispositions that the popup blocker does not evaluate, notably
  SAVE_TO_DISK and OFF_THE_RECORD, to NEW_FOREGROUND_TAB so that the
  underlying tab is never navigated and downloads cannot be started
  without a gesture.
- Reset the frame tree node identifier.

Validated requests are then routed through the tab's WebContents
delegate instead of the browser window interface, which attributes the
navigation to the tab so that it is evaluated against popup blocking
rules. Consequently the popup content setting is now evaluated against
the tab's URL. Requests from the side panel WebUI main frame originate
from browser-controlled code and continue to be forwarded unmodified.

Bug: 517689673
Change-Id: I89f62b3e84dc7c0d81d6a73fb55c0860c1cb229e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8414217
Commit-Queue: Juan Mojica <[email protected]>
Reviewed-by: Bryan Nguyen <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1698537}
---
diff --git a/chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc b/chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
index 9f3324d..2f8ff2e 100644
--- a/chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
+++ b/chrome/browser/ui/lens/lens_overlay_side_panel_web_view.cc
@@ -13,9 +13,15 @@
 #include "chrome/common/webui_url_constants.h"
 #include "chrome/grit/generated_resources.h"
 #include "components/input/native_web_keyboard_event.h"
+#include "components/tabs/public/tab_interface.h"
 #include "components/web_modal/web_contents_modal_dialog_manager.h"
 #include "content/public/browser/file_select_listener.h"
+#include "content/public/browser/global_routing_id.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/browser/web_contents_delegate.h"
 #include "ui/base/metadata/metadata_impl_macros.h"
+#include "ui/base/window_open_disposition.h"
 
 using SidePanelWebUIViewT_LensSidePanelUntrustedUI =
     SidePanelWebUIViewT<lens::LensSidePanelUntrustedUI>;
@@ -73,10 +79,115 @@
     const content::OpenURLParams& params,
     base::OnceCallback<void(content::NavigationHandle&)>
         navigation_handle_callback) {
-  coordinator_->GetLensSearchController()
-      ->GetTabInterface()
-      ->GetBrowserWindowInterface()
-      ->OpenURL(params, std::move(navigation_handle_callback));
+  // Note that `navigation_handle_callback` is dropped without being run on all
+  // of the early returns below. Callers must tolerate the callback never
+  // running, which is the same contract as any other rejected navigation.
+  if (!coordinator_ || !coordinator_->GetLensSearchController()) {
+    return nullptr;
+  }
+  tabs::TabInterface* tab =
+      coordinator_->GetLensSearchController()->GetTabInterface();
+  // A tab that is not attached to a browser window has nowhere to open the
+  // URL, so drop the request instead of navigating.
+  if (!tab || !tab->GetBrowserWindowInterface()) {
+    return nullptr;
+  }
+
+  // Resolve the frame that requested this navigation.
+  // `initiator_frame_token` is preferred because it identifies the frame even
+  // if it has since navigated; the routing ID is only a fallback for requests
+  // that do not carry a token.
+  content::RenderFrameHost* source_rfh = nullptr;
+  if (params.initiator_frame_token.has_value()) {
+    source_rfh = content::RenderFrameHost::FromFrameToken(
+        content::GlobalRenderFrameHostToken(
+            params.initiator_process_id, params.initiator_frame_token.value()));
+  } else {
+    source_rfh = content::RenderFrameHost::FromID(
+        params.source_render_process_id, params.source_render_frame_id);
+  }
+  // Only an initiator that actually lives in this side panel may be trusted;
+  // treat anything else as an unknown initiator so that its state (notably
+  // user activation) is not attributed to this side panel.
+  if (source_rfh && content::WebContents::FromRenderFrameHost(source_rfh) !=
+                        GetWebContents()) {
+    source_rfh = nullptr;
+  }
+
+  // Everything hosted by this WebContents is renderer content that the browser
+  // must not trust, including the chrome-untrusted:// WebUI main frame itself,
+  // so the scheme check below applies to every initiator without exception.
+  //
+  // The side panel only renders links from web content, so restrict forwarded
+  // navigations to web schemes. This intentionally also drops external
+  // protocol links (e.g. mailto:).
+  if (!params.url.SchemeIsHTTPOrHTTPS()) {
+    return nullptr;
+  }
+
+  content::OpenURLParams modified_params = params;
+
+  // The two checks below exist to catch a renderer lying about how a navigation
+  // was requested. They do not apply to requests the browser itself created,
+  // such as a context menu command, because those values are not renderer
+  // claims in the first place.
+  //
+  // A renderer hosted here cannot forge this bit. Navigator::RequestOpenURL()
+  // hardcodes `is_renderer_initiated` to true, and only clears it for a WebUI
+  // whose TrustPolicy is kTrusted. The side panel is LensSidePanelUntrustedUI,
+  // an UntrustedWebUIController, which is kUntrusted, and the results frame is
+  // ordinary web content with no WebUI at all.
+  if (params.is_renderer_initiated) {
+    // Check that user_gesture is really true, by confirming that there was a
+    // recent activation in the source render frame host. This is best effort:
+    // an unknown initiator is treated as having no activation.
+    if (modified_params.user_gesture &&
+        (!source_rfh || !source_rfh->HasTransientUserActivation())) {
+      modified_params.user_gesture = false;
+    }
+
+    // Only forward dispositions that open a new container and that
+    // `blocked_content::ConsiderForPopupBlocking()` actually evaluates.
+    // Anything else is demoted to NEW_FOREGROUND_TAB so that the popup blocker
+    // still runs and the underlying tab is never navigated. Notably,
+    // SAVE_TO_DISK and OFF_THE_RECORD are deliberately excluded: the popup
+    // blocker ignores both, so allowing them would let the results frame start
+    // downloads or open off-the-record windows with no user gesture.
+    //
+    // `default` is used deliberately so that any disposition added in the
+    // future is demoted rather than silently forwarded.
+    switch (modified_params.disposition) {
+      case WindowOpenDisposition::NEW_FOREGROUND_TAB:
+      case WindowOpenDisposition::NEW_BACKGROUND_TAB:
+      case WindowOpenDisposition::NEW_POPUP:
+      case WindowOpenDisposition::NEW_WINDOW:
+        break;
+      default:
+        modified_params.disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB;
+        break;
+    }
+  }
+
+  // Reset frame_tree_node_id to prevent mismatched frame tree lookups in the
+  // target navigation controller.
+  modified_params.frame_tree_node_id = content::FrameTreeNodeId();
+
+  // Pass the main tab's WebContents as the source so the navigation pipeline
+  // has the correct context to evaluate disposition and blocking rules.
+  content::WebContents* tab_contents = tab->GetContents();
+  if (tab_contents && tab_contents->GetDelegate()) {
+    tab_contents->GetDelegate()->OpenURLFromTab(
+        tab_contents, modified_params, std::move(navigation_handle_callback));
+  }
+
+  // Always return nullptr, even when the navigation succeeded and created a
+  // WebContents. Returning it would make WebContentsImpl::OpenURL() notify
+  // WebContentsObserver::DidOpenRequestedURL(), and
+  // LensOverlaySidePanelCoordinator observes this WebContents and reacts to
+  // that notification by opening the URL in the browser itself. The URL would
+  // then be opened twice for a single request. That observer exists to service
+  // the WebContentsImpl::CreateNewWindow() path (e.g. target="_blank"), which
+  // this method does not go through.
   return nullptr;
 }
 
diff --git a/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc b/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
index 0e8d3981..4e196e86 100644
--- a/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
+++ b/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
@@ -17,6 +17,7 @@
 #include "base/files/file_util.h"
 #include "base/memory/raw_ptr_exclusion.h"
 #include "base/path_service.h"
+#include "base/run_loop.h"
 #include "base/strings/strcat.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/string_util.h"
@@ -103,6 +104,7 @@
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/base32/base32.h"
+#include "components/blocked_content/popup_blocker_tab_helper.h"
 #include "components/constrained_window/constrained_window_views.h"
 #include "components/contextual_tasks/public/features.h"
 #include "components/feature_engagement/public/feature_constants.h"
@@ -912,6 +914,93 @@
     lens::WaitForPaint(browser(), url, disposition, browser_test_flags);
   }
 
+  // Opens the overlay and the results side panel, then returns the results
+  // frame hosted inside the side panel WebContents. Returns nullptr if any step
+  // fails; callers should ASSERT on the result before using it.
+  content::RenderFrameHost* OpenSidePanelAndGetResultsFrame() {
+    auto* controller = GetLensOverlayController();
+    OpenLensOverlay(LensOverlayInvocationSource::kAppMenu);
+    if (!base::test::RunUntil(
+            [&]() { return controller->state() == State::kOverlay; })) {
+      return nullptr;
+    }
+    if (!content::WaitForLoadStop(GetOverlayWebContents())) {
+      return nullptr;
+    }
+
+    controller->OpenSidePanelForTesting();
+    GetLensOverlaySidePanelCoordinator()->LoadURLInResultsFrameForTesting(
+        GURL("https://www.google.com/search"));
+    if (!IsLensResultsSidePanelShowing()) {
+      return nullptr;
+    }
+    content::WebContents* side_panel_contents =
+        controller->GetSidePanelWebContentsForTesting();
+    if (!side_panel_contents ||
+        !content::WaitForLoadStop(side_panel_contents)) {
+      return nullptr;
+    }
+
+    // The results frame is the only child frame of the side panel web contents.
+    return content::ChildFrameAt(side_panel_contents->GetPrimaryMainFrame(), 0);
+  }
+
+  // Builds the OpenURLParams that the browser would construct for a navigation
+  // requested by `initiator`.
+  content::OpenURLParams MakeRendererInitiatedParams(
+      content::RenderFrameHost* initiator,
+      const GURL& url,
+      WindowOpenDisposition disposition,
+      bool user_gesture,
+      content::FrameTreeNodeId frame_tree_node_id =
+          content::FrameTreeNodeId()) {
+    content::OpenURLParams params(url, content::Referrer(), frame_tree_node_id,
+                                  disposition, ui::PAGE_TRANSITION_LINK,
+                                  /*is_renderer_initiated=*/true);
+    params.user_gesture = user_gesture;
+    params.initiator_origin = initiator->GetLastCommittedOrigin();
+    params.initiator_frame_token = initiator->GetFrameToken();
+    params.initiator_process_id = initiator->GetProcess()->GetDeprecatedID();
+    params.source_site_instance = initiator->GetSiteInstance();
+    params.source_render_process_id =
+        initiator->GetProcess()->GetDeprecatedID();
+    params.source_render_frame_id = initiator->GetRoutingID();
+    return params;
+  }
+
+  // Builds the OpenURLParams that RenderViewContextMenuBase constructs when a
+  // link inside `initiator` is right clicked and a command such as "Open link
+  // in new tab" is chosen. These are browser initiated, which is why
+  // `user_gesture` defaults to true.
+  content::OpenURLParams MakeContextMenuParams(
+      content::RenderFrameHost* initiator,
+      const GURL& url,
+      WindowOpenDisposition disposition) {
+    content::OpenURLParams params =
+        content::OpenURLParams::CreateBrowserInitiated(
+            url, disposition, ui::PAGE_TRANSITION_LINK, content::Referrer(),
+            /*started_from_context_menu=*/true);
+    params.source_render_process_id =
+        initiator->GetProcess()->GetDeprecatedID();
+    params.source_render_frame_id = initiator->GetRoutingID();
+    params.initiator_frame_token = initiator->GetFrameToken();
+    params.initiator_process_id = initiator->GetProcess()->GetDeprecatedID();
+    params.initiator_origin = initiator->GetLastCommittedOrigin();
+    params.source_site_instance = initiator->GetSiteInstance();
+    return params;
+  }
+
+  // Runs every task that was already posted to this thread's task queue. Task
+  // runners are FIFO, so once the closure posted below runs, anything posted
+  // before it has run too. This is used to assert that a task which would have
+  // opened an extra tab did not do so.
+  void WaitForPreviouslyPostedTasks() {
+    base::RunLoop run_loop;
+    base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE, run_loop.QuitClosure());
+    run_loop.Run();
+  }
+
   // Helper to remove the start time, client upload duration, and viewport size
   // query params from the url.
   GURL RemoveStartTimeAndSizeParams(const GURL& url_to_process) {
@@ -2634,6 +2723,408 @@
                   .ExtractBool());
 }
 
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_CurrentTabFromSubframe_DoesNotNavigateCurrentTab) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  ASSERT_TRUE(results_frame->GetParentOrOuterDocument());
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+
+  // Request a CURRENT_TAB navigation carrying the results frame's frame tree
+  // node id, which belongs to the side panel rather than to the tab being
+  // navigated.
+  const content::OpenURLParams params = MakeRendererInitiatedParams(
+      results_frame, embedded_test_server()->GetURL("/title1.html"),
+      WindowOpenDisposition::CURRENT_TAB, /*user_gesture=*/true,
+      results_frame->GetFrameTreeNodeId());
+
+  // The request should be handled safely without crashing on frame tree
+  // lookups or navigating the current active tab.
+  side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+  // Verify the active tab was not navigated, not even asynchronously.
+  EXPECT_FALSE(active_tab->GetController().GetPendingEntry());
+  EXPECT_EQ(initial_active_url, active_tab->GetLastCommittedURL());
+  EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+}
+
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_WithoutUserActivation_BlocksPopup) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker = blocked_content::PopupBlockerTabHelper::FromWebContents(
+      browser()->GetTabStripModel()->GetActiveWebContents());
+  ASSERT_TRUE(popup_blocker);
+
+  // Send OpenURL requests with user_gesture = false and no transient
+  // activation anywhere.
+  const WindowOpenDisposition kDispositions[] = {
+      WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      WindowOpenDisposition::NEW_FOREGROUND_TAB,
+  };
+
+  size_t expected_blocked_popups = 0;
+  for (WindowOpenDisposition disposition : kDispositions) {
+    SCOPED_TRACE(testing::Message()
+                 << "disposition " << static_cast<int>(disposition));
+    const content::OpenURLParams params = MakeRendererInitiatedParams(
+        results_frame, embedded_test_server()->GetURL("/title1.html"),
+        disposition, /*user_gesture=*/false);
+
+    side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+    // Verify that the request is evaluated against popup blocking rules and
+    // does not open a new tab.
+    ++expected_blocked_popups;
+    EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+    EXPECT_EQ(expected_blocked_popups, popup_blocker->GetBlockedPopupsCount());
+  }
+}
+
+// The side panel WebUI main frame is chrome-untrusted:// content, so requests
+// it initiates must be validated exactly like requests from the results frame.
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_CurrentTabFromWebUIMainFrame_IsAlsoValidated) {
+  WaitForPaint();
+
+  ASSERT_TRUE(OpenSidePanelAndGetResultsFrame());
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+  content::RenderFrameHost* main_frame =
+      side_panel_contents->GetPrimaryMainFrame();
+  ASSERT_TRUE(main_frame);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker =
+      blocked_content::PopupBlockerTabHelper::FromWebContents(active_tab);
+  ASSERT_TRUE(popup_blocker);
+
+  const content::OpenURLParams params = MakeRendererInitiatedParams(
+      main_frame, embedded_test_server()->GetURL("/title1.html"),
+      WindowOpenDisposition::CURRENT_TAB, /*user_gesture=*/true,
+      main_frame->GetFrameTreeNodeId());
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc b/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
index 0e8d3981..4e196e86 100644
--- a/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
+++ b/chrome/browser/ui/lens/lens_overlay_controller_browsertest.cc
@@ -17,6 +17,7 @@
 #include "base/files/file_util.h"
 #include "base/memory/raw_ptr_exclusion.h"
 #include "base/path_service.h"
+#include "base/run_loop.h"
 #include "base/strings/strcat.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/string_util.h"
@@ -103,6 +104,7 @@
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/base32/base32.h"
+#include "components/blocked_content/popup_blocker_tab_helper.h"
 #include "components/constrained_window/constrained_window_views.h"
 #include "components/contextual_tasks/public/features.h"
 #include "components/feature_engagement/public/feature_constants.h"
@@ -912,6 +914,93 @@
     lens::WaitForPaint(browser(), url, disposition, browser_test_flags);
   }
 
+  // Opens the overlay and the results side panel, then returns the results
+  // frame hosted inside the side panel WebContents. Returns nullptr if any step
+  // fails; callers should ASSERT on the result before using it.
+  content::RenderFrameHost* OpenSidePanelAndGetResultsFrame() {
+    auto* controller = GetLensOverlayController();
+    OpenLensOverlay(LensOverlayInvocationSource::kAppMenu);
+    if (!base::test::RunUntil(
+            [&]() { return controller->state() == State::kOverlay; })) {
+      return nullptr;
+    }
+    if (!content::WaitForLoadStop(GetOverlayWebContents())) {
+      return nullptr;
+    }
+
+    controller->OpenSidePanelForTesting();
+    GetLensOverlaySidePanelCoordinator()->LoadURLInResultsFrameForTesting(
+        GURL("https://www.google.com/search"));
+    if (!IsLensResultsSidePanelShowing()) {
+      return nullptr;
+    }
+    content::WebContents* side_panel_contents =
+        controller->GetSidePanelWebContentsForTesting();
+    if (!side_panel_contents ||
+        !content::WaitForLoadStop(side_panel_contents)) {
+      return nullptr;
+    }
+
+    // The results frame is the only child frame of the side panel web contents.
+    return content::ChildFrameAt(side_panel_contents->GetPrimaryMainFrame(), 0);
+  }
+
+  // Builds the OpenURLParams that the browser would construct for a navigation
+  // requested by `initiator`.
+  content::OpenURLParams MakeRendererInitiatedParams(
+      content::RenderFrameHost* initiator,
+      const GURL& url,
+      WindowOpenDisposition disposition,
+      bool user_gesture,
+      content::FrameTreeNodeId frame_tree_node_id =
+          content::FrameTreeNodeId()) {
+    content::OpenURLParams params(url, content::Referrer(), frame_tree_node_id,
+                                  disposition, ui::PAGE_TRANSITION_LINK,
+                                  /*is_renderer_initiated=*/true);
+    params.user_gesture = user_gesture;
+    params.initiator_origin = initiator->GetLastCommittedOrigin();
+    params.initiator_frame_token = initiator->GetFrameToken();
+    params.initiator_process_id = initiator->GetProcess()->GetDeprecatedID();
+    params.source_site_instance = initiator->GetSiteInstance();
+    params.source_render_process_id =
+        initiator->GetProcess()->GetDeprecatedID();
+    params.source_render_frame_id = initiator->GetRoutingID();
+    return params;
+  }
+
+  // Builds the OpenURLParams that RenderViewContextMenuBase constructs when a
+  // link inside `initiator` is right clicked and a command such as "Open link
+  // in new tab" is chosen. These are browser initiated, which is why
+  // `user_gesture` defaults to true.
+  content::OpenURLParams MakeContextMenuParams(
+      content::RenderFrameHost* initiator,
+      const GURL& url,
+      WindowOpenDisposition disposition) {
+    content::OpenURLParams params =
+        content::OpenURLParams::CreateBrowserInitiated(
+            url, disposition, ui::PAGE_TRANSITION_LINK, content::Referrer(),
+            /*started_from_context_menu=*/true);
+    params.source_render_process_id =
+        initiator->GetProcess()->GetDeprecatedID();
+    params.source_render_frame_id = initiator->GetRoutingID();
+    params.initiator_frame_token = initiator->GetFrameToken();
+    params.initiator_process_id = initiator->GetProcess()->GetDeprecatedID();
+    params.initiator_origin = initiator->GetLastCommittedOrigin();
+    params.source_site_instance = initiator->GetSiteInstance();
+    return params;
+  }
+
+  // Runs every task that was already posted to this thread's task queue. Task
+  // runners are FIFO, so once the closure posted below runs, anything posted
+  // before it has run too. This is used to assert that a task which would have
+  // opened an extra tab did not do so.
+  void WaitForPreviouslyPostedTasks() {
+    base::RunLoop run_loop;
+    base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+        FROM_HERE, run_loop.QuitClosure());
+    run_loop.Run();
+  }
+
   // Helper to remove the start time, client upload duration, and viewport size
   // query params from the url.
   GURL RemoveStartTimeAndSizeParams(const GURL& url_to_process) {
@@ -2634,6 +2723,408 @@
                   .ExtractBool());
 }
 
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_CurrentTabFromSubframe_DoesNotNavigateCurrentTab) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  ASSERT_TRUE(results_frame->GetParentOrOuterDocument());
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+
+  // Request a CURRENT_TAB navigation carrying the results frame's frame tree
+  // node id, which belongs to the side panel rather than to the tab being
+  // navigated.
+  const content::OpenURLParams params = MakeRendererInitiatedParams(
+      results_frame, embedded_test_server()->GetURL("/title1.html"),
+      WindowOpenDisposition::CURRENT_TAB, /*user_gesture=*/true,
+      results_frame->GetFrameTreeNodeId());
+
+  // The request should be handled safely without crashing on frame tree
+  // lookups or navigating the current active tab.
+  side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+  // Verify the active tab was not navigated, not even asynchronously.
+  EXPECT_FALSE(active_tab->GetController().GetPendingEntry());
+  EXPECT_EQ(initial_active_url, active_tab->GetLastCommittedURL());
+  EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+}
+
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_WithoutUserActivation_BlocksPopup) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker = blocked_content::PopupBlockerTabHelper::FromWebContents(
+      browser()->GetTabStripModel()->GetActiveWebContents());
+  ASSERT_TRUE(popup_blocker);
+
+  // Send OpenURL requests with user_gesture = false and no transient
+  // activation anywhere.
+  const WindowOpenDisposition kDispositions[] = {
+      WindowOpenDisposition::NEW_BACKGROUND_TAB,
+      WindowOpenDisposition::NEW_FOREGROUND_TAB,
+  };
+
+  size_t expected_blocked_popups = 0;
+  for (WindowOpenDisposition disposition : kDispositions) {
+    SCOPED_TRACE(testing::Message()
+                 << "disposition " << static_cast<int>(disposition));
+    const content::OpenURLParams params = MakeRendererInitiatedParams(
+        results_frame, embedded_test_server()->GetURL("/title1.html"),
+        disposition, /*user_gesture=*/false);
+
+    side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+    // Verify that the request is evaluated against popup blocking rules and
+    // does not open a new tab.
+    ++expected_blocked_popups;
+    EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+    EXPECT_EQ(expected_blocked_popups, popup_blocker->GetBlockedPopupsCount());
+  }
+}
+
+// The side panel WebUI main frame is chrome-untrusted:// content, so requests
+// it initiates must be validated exactly like requests from the results frame.
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_CurrentTabFromWebUIMainFrame_IsAlsoValidated) {
+  WaitForPaint();
+
+  ASSERT_TRUE(OpenSidePanelAndGetResultsFrame());
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+  content::RenderFrameHost* main_frame =
+      side_panel_contents->GetPrimaryMainFrame();
+  ASSERT_TRUE(main_frame);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker =
+      blocked_content::PopupBlockerTabHelper::FromWebContents(active_tab);
+  ASSERT_TRUE(popup_blocker);
+
+  const content::OpenURLParams params = MakeRendererInitiatedParams(
+      main_frame, embedded_test_server()->GetURL("/title1.html"),
+      WindowOpenDisposition::CURRENT_TAB, /*user_gesture=*/true,
+      main_frame->GetFrameTreeNodeId());
+
+  side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+  // The request is demoted and popup blocked rather than forwarded verbatim,
+  // so the tab is neither navigated nor replaced.
+  EXPECT_EQ(1u, popup_blocker->GetBlockedPopupsCount());
+  EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+  EXPECT_FALSE(active_tab->GetController().GetPendingEntry());
+  EXPECT_EQ(initial_active_url, active_tab->GetLastCommittedURL());
+}
+
+// Dispositions that `blocked_content::ConsiderForPopupBlocking()` ignores must
+// not be forwarded as-is, otherwise the results frame could start downloads or
+// open off-the-record windows without a user gesture.
+IN_PROC_BROWSER_TEST_F(
+    LensOverlayControllerBrowserTest,
+    SidePanel_OpenURLFromTab_DemotesDispositionsThatSkipPopupBlocking) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker =
+      blocked_content::PopupBlockerTabHelper::FromWebContents(active_tab);
+  ASSERT_TRUE(popup_blocker);
+
+  const WindowOpenDisposition kDispositions[] = {
+      WindowOpenDisposition::SAVE_TO_DISK,
+      WindowOpenDisposition::OFF_THE_RECORD,
+      WindowOpenDisposition::CURRENT_TAB,
+      WindowOpenDisposition::SWITCH_TO_TAB,
+      WindowOpenDisposition::NEW_PICTURE_IN_PICTURE,
+  };
+
+  size_t expected_blocked_popups = 0;
+  for (WindowOpenDisposition disposition : kDispositions) {
+    SCOPED_TRACE(testing::Message()
+                 << "disposition " << static_cast<int>(disposition));
+    const content::OpenURLParams params = MakeRendererInitiatedParams(
+        results_frame, embedded_test_server()->GetURL("/title1.html"),
+        disposition, /*user_gesture=*/true);
+
+    side_panel_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+
+    // Each request is demoted to NEW_FOREGROUND_TAB, and because there is no
+    // real transient activation it is then caught by the popup blocker.
+    ++expected_blocked_popups;
+    EXPECT_EQ(expected_blocked_popups, popup_blocker->GetBlockedPopupsCount());
+    EXPECT_EQ(initial_tab_count, browser()->GetTabStripModel()->count());
+    EXPECT_FALSE(active_tab->GetController().GetPendingEntry());
+    EXPECT_EQ(initial_active_url, active_tab->GetLastCommittedURL());
+  }
+}
+
+IN_PROC_BROWSER_TEST_F(LensOverlayControllerBrowserTest,
+                       SidePanel_OpenURLFromTab_BlocksNonWebSchemes) {
+  WaitForPaint();
+
+  content::RenderFrameHost* results_frame = OpenSidePanelAndGetResultsFrame();
+  ASSERT_TRUE(results_frame);
+  content::WebContents* side_panel_contents =
+      GetLensOverlayController()->GetSidePanelWebContentsForTesting();
+  ASSERT_TRUE(side_panel_contents);
+
+  content::WebContents* active_tab =
+      browser()->GetTabStripModel()->GetActiveWebContents();
+  const GURL initial_active_url = active_tab->GetLastCommittedURL();
+  const GURL initial_side_panel_url =
+      side_panel_contents->GetLastCommittedURL();
+  const int initial_tab_count = browser()->GetTabStripModel()->count();
+  auto* popup_blocker =
+      blocked_content::PopupBlockerTabHelper::FromWebContents(active_tab);
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.