Medium chrome OOB 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Navigation
DescriptionOut of bounds read in Navigation
ComponentNavigation
Bug ClassOOB
Tracker496650333
Fix commit7815ce630360 (chromium/src) +347/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/navigator.cc
modified

Files Changed

  • content/browser/renderer_host/navigator.cc
  • content/browser/security_exploit_browsertest.cc
  • content/browser/webui/web_ui_security_browsertest.cc
From 7815ce630360134b1c0e60389ec49d4642cbc15c Mon Sep 17 00:00:00 2001
From: Nasko Oskov <nasko@chromium.org>
Date: Mon, 28 Sep 2026 17:05:58 -0700
Subject: [PATCH] Enforce WebUI trust attribution from initiator in NavigateFromFrameProxy

When a frame navigates another frame via a remote frame proxy,
NavigateFromFrameProxy checks whether the navigation originates from a
trusted WebUI to determine if the navigation should be upgraded to
browser-initiated and whether the referrer should be stripped.

Previously, NavigateFromFrameProxy checked `render_frame_host->web_ui()`,
which represents the target frame being navigated. If an untrusted
renderer navigated a remote frame proxy targeting a WebUI page, the
navigation was improperly treated as browser-initiated, and its referrer
was stripped.

This CL fixes the trust check to look up the initiator RenderFrameHost
via RenderFrameHostImpl::FromFrameToken(initiator_process_id,
*initiator_frame_token) and check its web_ui() instead, matching the
pattern in Navigator::RequestOpenURL.

If the initiator frame token is absent or belongs to a frame that was
already destroyed, FromFrameToken returns nullptr, safely falling back
to treating the navigation as renderer-initiated.

TAG=agy
CONV=8fea1fd0-fbfb-482a-a2fa-aac1c74bc30e

Bug: 496650333
Change-Id: I78ab2da5deba9684d04800cc74e1287c12f01f1f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8429459
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: Nasko Oskov <nasko@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1706519}
---
diff --git a/content/browser/renderer_host/navigator.cc b/content/browser/renderer_host/navigator.cc
index 4da4a8d4..ee261a5 100644
--- a/content/browser/renderer_host/navigator.cc
+++ b/content/browser/renderer_host/navigator.cc
@@ -1214,22 +1214,26 @@
     return;
   }
 
-  // TODO(creis): Determine if this transfer started as a browser-initiated
-  // navigation.  See https://crbug.com/495161.
+  RenderFrameHostImpl* initiator_rfh =
+      initiator_frame_token ? RenderFrameHostImpl::FromFrameToken(
+                                  initiator_process_id, *initiator_frame_token)
+                            : nullptr;
   bool is_renderer_initiated = true;
   Referrer referrer_to_use(referrer);
-  if (WebUI* web_ui = render_frame_host->web_ui()) {
-    // Note that we hide the referrer for Web UI pages. We don't really want
-    // web sites to see a referrer of "chrome://blah" (and some chrome: URLs
-    // might have search terms or other stuff we don't want to send to the
-    // site), so we send no referrer.
-    referrer_to_use = Referrer();
+  if (initiator_rfh) {
+    if (WebUI* web_ui = initiator_rfh->web_ui()) {
+      // Note that we hide the referrer for Web UI pages. We don't really want
+      // web sites to see a referrer of "chrome://blah" (and some chrome: URLs
+      // might have search terms or other stuff we don't want to send to the
+      // site), so we send no referrer.
+      referrer_to_use = Referrer();
 
-    // Navigations in trusted Web UI pages count as browser-initiated
-    // navigations.
-    if (web_ui->GetController()->GetTrustPolicy() ==
-        WebUIController::TrustPolicy::kTrusted) {
-      is_renderer_initiated = false;
+      // Navigations in trusted Web UI pages count as browser-initiated
+      // navigations.
+      if (web_ui->GetController()->GetTrustPolicy() ==
+          WebUIController::TrustPolicy::kTrusted) {
+        is_renderer_initiated = false;
+      }
     }
   }
 
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 0ba83bb1..06d2553b 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -78,12 +78,14 @@
 #include "content/public/test/content_browser_test_utils.h"
 #include "content/public/test/navigation_handle_observer.h"
 #include "content/public/test/render_frame_host_test_support.h"
+#include "content/public/test/scoped_web_ui_controller_factory_registration.h"
 #include "content/public/test/test_content_browser_client.h"
 #include "content/public/test/test_frame_navigation_observer.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "content/public/test/test_renderer_host.h"
 #include "content/public/test/test_utils.h"
 #include "content/public/test/url_loader_interceptor.h"
+#include "content/public/test/web_ui_browsertest_util.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
 #include "content/test/did_commit_navigation_interceptor.h"
@@ -145,6 +147,7 @@
 #include "third_party/blink/public/mojom/page/widget.mojom.h"
 #include "third_party/blink/public/mojom/permissions/permission.mojom.h"
 #include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
+#include "ui/webui/untrusted_web_ui_browsertest_util.h"
 
 using ::testing::HasSubstr;
 using ::testing::Optional;
@@ -6585,5 +6588,274 @@
       "Navigation.InitialDocument.NonEmptyCompositorFrameTreatedAsAccess", true,
       1);
 }
+// Test that when an unprivileged cross-process iframe embedded in a WebUI page
+// navigates its parent WebUI frame via RemoteFrameHost::OpenURL, the resulting
+// navigation is NOT upgraded to browser-initiated based on the target WebUI
+// frame. Regression test for crbug.com/496650333.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_UnprivilegedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+
+  // The proxy in the child frame's process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = child->current_frame_host()->GetFrameToken();
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
+
+// Test that when an untrusted WebUI iframe (chrome-untrusted://) embedded in a
+// WebUI page navigates its parent WebUI frame via RemoteFrameHost::OpenURL,
+// the navigation is NOT upgraded to browser-initiated because its trust
+// policy is kUntrusted.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_UntrustedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  TestUntrustedDataSourceHeaders headers;
+  headers.no_xfo = true;
+  headers.no_trusted_types = true;
+  ScopedWebUIConfigRegistration untrusted_registration(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-untrusted-webui",
+                                                     headers));
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src * chrome-untrusted:;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = GetChromeUntrustedUIURL("test-untrusted-webui/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+  ASSERT_TRUE(child->current_frame_host()->web_ui());
+  EXPECT_EQ(
+      WebUIController::TrustPolicy::kUntrusted,
+      child->current_frame_host()->web_ui()->GetController()->GetTrustPolicy());
+
+  // The proxy in the child frame's process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = child->current_frame_host()->GetFrameToken();
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_TRUE(handle->GetReferrer().url.is_empty());
+}
+
+// Test that if an initiating subframe is deleted/detached before the browser
+// processes OpenURL targeting a WebUI frame (so FromFrameToken returns
+// nullptr), the navigation safely fails closed and remains renderer-initiated.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_DeletedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  // Load a WebUI page with a b.com iframe.
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  // Add a grandchild iframe inside child.
+  EXPECT_TRUE(ExecJs(child->current_frame_host(),
+                     "const f = document.createElement('iframe');"
+                     "f.src = '/title1.html';"
+                     "document.body.appendChild(f);"));
+  EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+  EXPECT_EQ(1u, child->child_count());
+  FrameTreeNode* grandchild = child->child_at(0);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  // Capture the grandchild's frame token before it is deleted.
+  blink::LocalFrameToken deleted_frame_token =
+      grandchild->current_frame_host()->GetFrameToken();
+
+  // The proxy in the b.com process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  // Delete the grandchild iframe from the DOM so its RenderFrameHost is
+  // destroyed.
+  EXPECT_TRUE(ExecJs(child->current_frame_host(),
+                     "document.querySelector('iframe').remove();"));
+  EXPECT_EQ(0u, child->child_count());
+
+  // Verify that FromFrameToken no longer finds the destroyed initiator frame.
+  EXPECT_FALSE(RenderFrameHostImpl::FromFrameToken(
+      child_instance->GetProcess()->GetID(), deleted_frame_token));
+
+  // Create OpenURL params using the live child frame's document state, but
+  // specifying the deleted frame's token as the initiator_frame_token.
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = deleted_frame_token;
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  // Send OpenURL from the proxy with the deleted initiator frame's token.
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
+
+// Test that if initiator_frame_token is nullopt when navigating a WebUI frame
+// via OpenURL, the navigation safely falls back to renderer-initiated.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_NullInitiatorTokenDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = std::nullopt;
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
 
 }  // namespace content
diff --git a/content/browser/webui/web_ui_security_browsertest.cc b/content/browser/webui/web_ui_security_browsertest.cc
index f4ba4f57..2d3e133e 100644
--- a/content/browser/webui/web_ui_security_browsertest.cc
+++ b/content/browser/webui/web_ui_security_browsertest.cc
@@ -12,6 +12,8 @@
 #include "base/threading/thread_restrictions.h"
 #include "content/browser/process_lock.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/renderer_host/render_frame_proxy_host.h"
 #include "content/browser/security/cpsp/child_process_security_policy_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/browser/webui/web_ui_controller_factory_registry.h"
@@ -39,6 +41,7 @@
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
 #include "net/base/url_util.h"
+#include "third_party/blink/public/mojom/frame/frame.mojom.h"
 #include "ui/webui/untrusted_web_ui_browsertest_util.h"
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 0ba83bb1..06d2553b 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -78,12 +78,14 @@
 #include "content/public/test/content_browser_test_utils.h"
 #include "content/public/test/navigation_handle_observer.h"
 #include "content/public/test/render_frame_host_test_support.h"
+#include "content/public/test/scoped_web_ui_controller_factory_registration.h"
 #include "content/public/test/test_content_browser_client.h"
 #include "content/public/test/test_frame_navigation_observer.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "content/public/test/test_renderer_host.h"
 #include "content/public/test/test_utils.h"
 #include "content/public/test/url_loader_interceptor.h"
+#include "content/public/test/web_ui_browsertest_util.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
 #include "content/test/did_commit_navigation_interceptor.h"
@@ -145,6 +147,7 @@
 #include "third_party/blink/public/mojom/page/widget.mojom.h"
 #include "third_party/blink/public/mojom/permissions/permission.mojom.h"
 #include "third_party/blink/public/mojom/widget/platform_widget.mojom.h"
+#include "ui/webui/untrusted_web_ui_browsertest_util.h"
 
 using ::testing::HasSubstr;
 using ::testing::Optional;
@@ -6585,5 +6588,274 @@
       "Navigation.InitialDocument.NonEmptyCompositorFrameTreatedAsAccess", true,
       1);
 }
+// Test that when an unprivileged cross-process iframe embedded in a WebUI page
+// navigates its parent WebUI frame via RemoteFrameHost::OpenURL, the resulting
+// navigation is NOT upgraded to browser-initiated based on the target WebUI
+// frame. Regression test for crbug.com/496650333.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_UnprivilegedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+
+  // The proxy in the child frame's process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = child->current_frame_host()->GetFrameToken();
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
+
+// Test that when an untrusted WebUI iframe (chrome-untrusted://) embedded in a
+// WebUI page navigates its parent WebUI frame via RemoteFrameHost::OpenURL,
+// the navigation is NOT upgraded to browser-initiated because its trust
+// policy is kUntrusted.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_UntrustedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  TestUntrustedDataSourceHeaders headers;
+  headers.no_xfo = true;
+  headers.no_trusted_types = true;
+  ScopedWebUIConfigRegistration untrusted_registration(
+      std::make_unique<ui::TestUntrustedWebUIConfig>("test-untrusted-webui",
+                                                     headers));
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src * chrome-untrusted:;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = GetChromeUntrustedUIURL("test-untrusted-webui/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+  ASSERT_TRUE(child->current_frame_host()->web_ui());
+  EXPECT_EQ(
+      WebUIController::TrustPolicy::kUntrusted,
+      child->current_frame_host()->web_ui()->GetController()->GetTrustPolicy());
+
+  // The proxy in the child frame's process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = child->current_frame_host()->GetFrameToken();
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_TRUE(handle->GetReferrer().url.is_empty());
+}
+
+// Test that if an initiating subframe is deleted/detached before the browser
+// processes OpenURL targeting a WebUI frame (so FromFrameToken returns
+// nullptr), the navigation safely fails closed and remains renderer-initiated.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_DeletedInitiatorDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  // Load a WebUI page with a b.com iframe.
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  // Add a grandchild iframe inside child.
+  EXPECT_TRUE(ExecJs(child->current_frame_host(),
+                     "const f = document.createElement('iframe');"
+                     "f.src = '/title1.html';"
+                     "document.body.appendChild(f);"));
+  EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+  EXPECT_EQ(1u, child->child_count());
+  FrameTreeNode* grandchild = child->child_at(0);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  // Capture the grandchild's frame token before it is deleted.
+  blink::LocalFrameToken deleted_frame_token =
+      grandchild->current_frame_host()->GetFrameToken();
+
+  // The proxy in the b.com process representing the WebUI root frame.
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  // Delete the grandchild iframe from the DOM so its RenderFrameHost is
+  // destroyed.
+  EXPECT_TRUE(ExecJs(child->current_frame_host(),
+                     "document.querySelector('iframe').remove();"));
+  EXPECT_EQ(0u, child->child_count());
+
+  // Verify that FromFrameToken no longer finds the destroyed initiator frame.
+  EXPECT_FALSE(RenderFrameHostImpl::FromFrameToken(
+      child_instance->GetProcess()->GetID(), deleted_frame_token));
+
+  // Create OpenURL params using the live child frame's document state, but
+  // specifying the deleted frame's token as the initiator_frame_token.
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = deleted_frame_token;
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  // Send OpenURL from the proxy with the deleted initiator frame's token.
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
+
+// Test that if initiator_frame_token is nullopt when navigating a WebUI frame
+// via OpenURL, the navigation safely falls back to renderer-initiated.
+IN_PROC_BROWSER_TEST_F(
+    SecurityExploitBrowserTest,
+    OpenURLFromRemoteFrame_WebUITarget_NullInitiatorTokenDoesNotUpgrade) {
+  TestWebUIControllerFactory factory;
+  ScopedWebUIControllerFactoryRegistration registration(&factory);
+
+  if (IsIsolatedOriginRequiredToGuaranteeDedicatedProcess()) {
+    IsolateOriginsForTesting(embedded_test_server(), shell()->web_contents(),
+                             {"b.com"});
+  }
+
+  GURL start_url = GetWebUIURL(
+      "test-webui/page_with_blank_iframe.html?noxfo=true&notrustedtypes=true"
+      "&childsrc=child-src *;");
+  EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  EXPECT_TRUE(root->current_frame_host()->web_ui());
+  EXPECT_EQ(1u, root->child_count());
+
+  FrameTreeNode* child = root->child_at(0);
+  GURL child_url = embedded_test_server()->GetURL("b.com", "/title1.html");
+  NavigateFrameToURL(child, child_url);
+
+  SiteInstanceImpl* child_instance =
+      child->current_frame_host()->GetSiteInstance();
+  EXPECT_NE(root->current_frame_host()->GetProcess(),
+            child->current_frame_host()->GetProcess());
+
+  RenderFrameProxyHost* proxy =
+      root->current_frame_host()
+          ->browsing_context_state()
+          ->GetRenderFrameProxyHost(child_instance->group());
+  ASSERT_TRUE(proxy);
+
+  GURL dest_url = embedded_test_server()->GetURL("b.com", "/title2.html");
+  TestNavigationManager nav_manager(shell()->web_contents(), dest_url);
+
+  auto params = CreateOpenURLParams(dest_url, child->current_frame_host());
+  params->initiator_frame_token = std::nullopt;
+  params->referrer = blink::mojom::Referrer::New(
+      child_url, network::mojom::ReferrerPolicy::kDefault);
+
+  static_cast<blink::mojom::RemoteFrameHost*>(proxy)->OpenURL(
+      std::move(params));
+
+  EXPECT_TRUE(nav_manager.WaitForRequestStart());
+
+  NavigationHandle* handle = nav_manager.GetNavigationHandle();
+  ASSERT_TRUE(handle);
+  EXPECT_TRUE(handle->IsRendererInitiated());
+  EXPECT_EQ(child_url, handle->GetReferrer().url);
+}
... (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.