Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUI misrepresentation in Navigation
DescriptionUI misrepresentation in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker514006744
Fix commitab2be7c3ec69 (chromium/src) +113/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
RenderFrameHostImplBrowserTestWithBFCacheAndViewTransition
content/browser/renderer_host/render_frame_host_impl_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/navigator.cc
  • content/browser/renderer_host/render_frame_host_impl_browsertest.cc
From ab2be7c3ec69432cff595bc629ccf14f294475e9 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Thu, 23 Jul 2026 14:01:10 -0700
Subject: [PATCH] Restrict initial-empty-document paint-holding exemption to browser-created tabs

Navigator::DidNavigate() allowed main-frame paint holding for any
navigation from an initial empty document. The exemption was added to
keep paint holding for the first navigation in a fresh browser-created
tab (crbug.com/367623929, seemingly for test purposes), but it also
applied to popups created via window.open(). An opener-created popup's
initial about:blank inherits the opener's origin and can be modified by
that opener via DOM APIs without clearing
is_on_initial_empty_document(). This would allow the opener to spoof a
URL if it navigated the popup cross-origin.

This CL tightens the initial empty doc exemption to browser-created tabs
which have unique opaque origins, with no precursors (rather than
inheriting an origin from an opener). Unique opaque origins can't be
scripted by anything, so paint holding remains safe for those cases. The
CL adds two tests (mostly Gemini-written) for openers with non-opaque
and opaque origins, which fail without the fix.

Bug: 514006744
Change-Id: Idd36cca191a0249e1130d68638866c99dd393eda
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8135131
Reviewed-by: Vladimir Levin <vmpstr@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1667390}
---

diff --git a/content/browser/renderer_host/navigator.cc b/content/browser/renderer_host/navigator.cc
index 6fb8891..b7bfdf2 100644
--- a/content/browser/renderer_host/navigator.cc
+++ b/content/browser/renderer_host/navigator.cc
@@ -563,8 +563,9 @@
   // Allow main frame paint holding in the following cases:
   //  - We don't have an animated transition. See crbug.com/360844863.
   //  - At least one of the following conditions is true:
-  //    - This is a navigation from the initial document. This part helps with
-  //      tests. See crbug.com/367623929.
+  //    - This is a navigation from the initial document (in cases where this is
+  //      a brand new tab that didn't inherit another origin from an opener).
+  //      This part helps with tests. See crbug.com/367623929.
   //    - This is a same origin navigation (or we're not limiting cross-origin
   //      paint holding)
   //    - There is a user activation. This means that the user interacted with
@@ -577,9 +578,12 @@
   // See https://issues.chromium.org/40942531 for reasons we limit paint
   // holding.
   ContentBrowserClient* client = GetContentClient()->browser();
+  const bool allow_paint_holding_for_initial_empty_document =
+      was_on_initial_empty_document && old_frame_origin.opaque() &&
+      !old_frame_origin.GetTupleOrPrecursorTupleIfOpaque().IsValid();
   const bool allow_main_frame_paint_holding =
       !navigation_request->was_initiated_by_animated_transition() &&
-      (was_on_initial_empty_document ||
+      (allow_paint_holding_for_initial_empty_document ||
        old_frame_origin.IsSameOriginWith(params.origin) ||
        old_frame_host->HasStickyUserActivation() ||
        client->AllowNonActivatedCrossOriginPaintHolding() ||
diff --git a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
index 62c2f2a8..120af00 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -9705,6 +9705,112 @@
   EXPECT_FALSE(rwhi_a->IsContentRenderingTimeoutRunning());
 }
 
+// Tests that paint holding is not used when an opener-created popup that is
+// still on its initial empty document navigates cross-origin without user
+// activation. The popup's initial empty document inherits the opener's origin,
+// so the opener can write content into it; that content should not remain on
+// screen as paint-holding fallback once the omnibox shows the new cross-origin
+// URL.
+IN_PROC_BROWSER_TEST_F(
+    RenderFrameHostImplBrowserTest,
+    NoPaintHoldingForCrossOriginNavigationFromOpenerCreatedInitialDocument) {
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+  // Navigate the opener to a.com.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+
+  // Open a popup. Its initial empty document inherits a.com's origin and can
+  // be modified by the opener.
+  ShellAddedObserver new_shell_observer;
+  EXPECT_TRUE(ExecJs(
+      shell(), "window.w = window.open(); w.document.body.innerHTML = 'hi';"));
+  Shell* popup = new_shell_observer.GetShell();
+  WebContentsImpl* popup_contents =
+      static_cast<WebContentsImpl*>(popup->web_contents());
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+
+  RenderFrameHostImpl* popup_rfh = popup_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(popup_rfh->frame_tree_node()->is_on_initial_empty_document());
+  EXPECT_FALSE(popup_rfh->GetLastCommittedOrigin().opaque());
+  EXPECT_FALSE(popup_rfh->HasStickyUserActivation());
+
+  // Navigate the popup cross-site from the opener, as a real page would.
+  TestNavigationObserver nav_observer(popup_contents);
+  EXPECT_TRUE(ExecJs(shell(), JsReplace("window.w.location = $1;", url_b),
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+  nav_observer.Wait();
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+  EXPECT_EQ(url_b, popup_contents->GetLastCommittedURL());
+
+  // Paint holding should not be used for this cross-origin, non-activated
+  // navigation, even though it started from the initial empty document. The
+  // content rendering timeout is used here as a proxy for paint holding.
+  RenderWidgetHostImpl* popup_rwhi =
+      popup_contents->GetPrimaryMainFrame()->GetRenderWidgetHost();
+  EXPECT_FALSE(popup_rwhi->IsContentRenderingTimeoutRunning());
+}
+
+// Tests that paint holding is not used when an opener-created popup that is
+// still on its initial empty document navigates cross-origin without user
+// activation. Similar to the test above, but checks the case where the opener
+// has an opaque origin.
+IN_PROC_BROWSER_TEST_F(
+    RenderFrameHostImplBrowserTest,
+    NoPaintHoldingForCrossOriginNavigationFromOpaqueOpenerCreatedInitialDocument) {
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+  // Navigate the opener to a.com.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+
+  // Create a data: URL iframe to give the attacker an opaque origin.
+  TestNavigationObserver data_nav_observer(shell()->web_contents());
+  ShellAddedObserver new_shell_observer;
+  EXPECT_TRUE(ExecJs(shell(), R"(
+    let f = document.createElement('iframe');
+    f.src = 'data:text/html,<script>window.w = window.open(); ' +
+            'w.document.body.innerHTML = "hi";</script>';
+    document.body.appendChild(f);
+  )"));
+  data_nav_observer.Wait();
+
+  RenderFrameHostImpl* data_rfh = static_cast<RenderFrameHostImpl*>(
+      ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0));
+  EXPECT_TRUE(data_rfh->GetLastCommittedOrigin().opaque());
+
+  // Wait for the popup to be created and load.
+  Shell* popup = new_shell_observer.GetShell();
+  WebContentsImpl* popup_contents =
+      static_cast<WebContentsImpl*>(popup->web_contents());
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+
+  RenderFrameHostImpl* popup_rfh = popup_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(popup_rfh->frame_tree_node()->is_on_initial_empty_document());
+  EXPECT_TRUE(popup_rfh->GetLastCommittedOrigin().opaque());
+  EXPECT_TRUE(data_rfh->GetLastCommittedOrigin()
+                  .GetTupleOrPrecursorTupleIfOpaque()
+                  .IsValid());
+  EXPECT_EQ(
+      data_rfh->GetLastCommittedOrigin().GetTupleOrPrecursorTupleIfOpaque(),
+      popup_rfh->GetLastCommittedOrigin().GetTupleOrPrecursorTupleIfOpaque());
+  EXPECT_FALSE(popup_rfh->HasStickyUserActivation());
+
+  // Navigate the popup cross-site from the opener, as a real page would.
+  TestNavigationObserver nav_observer(popup_contents);
+  EXPECT_TRUE(ExecJs(data_rfh, JsReplace("window.w.location = $1;", url_b),
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+  nav_observer.Wait();
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+  EXPECT_EQ(url_b, popup_contents->GetLastCommittedURL());
+
+  // Paint holding should not be used for this cross-origin, non-activated
+  // navigation.
+  RenderWidgetHostImpl* popup_rwhi =
+      popup_contents->GetPrimaryMainFrame()->GetRenderWidgetHost();
+  EXPECT_FALSE(popup_rwhi->IsContentRenderingTimeoutRunning());
+}
+
 namespace {
 
 class RenderFrameHostImplBrowserTestWithBFCacheAndViewTransition
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
index 62c2f2a8..120af00 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -9705,6 +9705,112 @@
   EXPECT_FALSE(rwhi_a->IsContentRenderingTimeoutRunning());
 }
 
+// Tests that paint holding is not used when an opener-created popup that is
+// still on its initial empty document navigates cross-origin without user
+// activation. The popup's initial empty document inherits the opener's origin,
+// so the opener can write content into it; that content should not remain on
+// screen as paint-holding fallback once the omnibox shows the new cross-origin
+// URL.
+IN_PROC_BROWSER_TEST_F(
+    RenderFrameHostImplBrowserTest,
+    NoPaintHoldingForCrossOriginNavigationFromOpenerCreatedInitialDocument) {
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+  // Navigate the opener to a.com.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+
+  // Open a popup. Its initial empty document inherits a.com's origin and can
+  // be modified by the opener.
+  ShellAddedObserver new_shell_observer;
+  EXPECT_TRUE(ExecJs(
+      shell(), "window.w = window.open(); w.document.body.innerHTML = 'hi';"));
+  Shell* popup = new_shell_observer.GetShell();
+  WebContentsImpl* popup_contents =
+      static_cast<WebContentsImpl*>(popup->web_contents());
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+
+  RenderFrameHostImpl* popup_rfh = popup_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(popup_rfh->frame_tree_node()->is_on_initial_empty_document());
+  EXPECT_FALSE(popup_rfh->GetLastCommittedOrigin().opaque());
+  EXPECT_FALSE(popup_rfh->HasStickyUserActivation());
+
+  // Navigate the popup cross-site from the opener, as a real page would.
+  TestNavigationObserver nav_observer(popup_contents);
+  EXPECT_TRUE(ExecJs(shell(), JsReplace("window.w.location = $1;", url_b),
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+  nav_observer.Wait();
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+  EXPECT_EQ(url_b, popup_contents->GetLastCommittedURL());
+
+  // Paint holding should not be used for this cross-origin, non-activated
+  // navigation, even though it started from the initial empty document. The
+  // content rendering timeout is used here as a proxy for paint holding.
+  RenderWidgetHostImpl* popup_rwhi =
+      popup_contents->GetPrimaryMainFrame()->GetRenderWidgetHost();
+  EXPECT_FALSE(popup_rwhi->IsContentRenderingTimeoutRunning());
+}
+
+// Tests that paint holding is not used when an opener-created popup that is
+// still on its initial empty document navigates cross-origin without user
+// activation. Similar to the test above, but checks the case where the opener
+// has an opaque origin.
+IN_PROC_BROWSER_TEST_F(
+    RenderFrameHostImplBrowserTest,
+    NoPaintHoldingForCrossOriginNavigationFromOpaqueOpenerCreatedInitialDocument) {
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+  // Navigate the opener to a.com.
+  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+
+  // Create a data: URL iframe to give the attacker an opaque origin.
+  TestNavigationObserver data_nav_observer(shell()->web_contents());
+  ShellAddedObserver new_shell_observer;
+  EXPECT_TRUE(ExecJs(shell(), R"(
+    let f = document.createElement('iframe');
+    f.src = 'data:text/html,<script>window.w = window.open(); ' +
+            'w.document.body.innerHTML = "hi";</script>';
+    document.body.appendChild(f);
+  )"));
+  data_nav_observer.Wait();
+
+  RenderFrameHostImpl* data_rfh = static_cast<RenderFrameHostImpl*>(
+      ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0));
+  EXPECT_TRUE(data_rfh->GetLastCommittedOrigin().opaque());
+
+  // Wait for the popup to be created and load.
+  Shell* popup = new_shell_observer.GetShell();
+  WebContentsImpl* popup_contents =
+      static_cast<WebContentsImpl*>(popup->web_contents());
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+
+  RenderFrameHostImpl* popup_rfh = popup_contents->GetPrimaryMainFrame();
+  EXPECT_TRUE(popup_rfh->frame_tree_node()->is_on_initial_empty_document());
+  EXPECT_TRUE(popup_rfh->GetLastCommittedOrigin().opaque());
+  EXPECT_TRUE(data_rfh->GetLastCommittedOrigin()
+                  .GetTupleOrPrecursorTupleIfOpaque()
+                  .IsValid());
+  EXPECT_EQ(
+      data_rfh->GetLastCommittedOrigin().GetTupleOrPrecursorTupleIfOpaque(),
+      popup_rfh->GetLastCommittedOrigin().GetTupleOrPrecursorTupleIfOpaque());
+  EXPECT_FALSE(popup_rfh->HasStickyUserActivation());
+
+  // Navigate the popup cross-site from the opener, as a real page would.
+  TestNavigationObserver nav_observer(popup_contents);
+  EXPECT_TRUE(ExecJs(data_rfh, JsReplace("window.w.location = $1;", url_b),
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+  nav_observer.Wait();
+  EXPECT_TRUE(WaitForLoadStop(popup_contents));
+  EXPECT_EQ(url_b, popup_contents->GetLastCommittedURL());
+
+  // Paint holding should not be used for this cross-origin, non-activated
+  // navigation.
+  RenderWidgetHostImpl* popup_rwhi =
+      popup_contents->GetPrimaryMainFrame()->GetRenderWidgetHost();
+  EXPECT_FALSE(popup_rwhi->IsContentRenderingTimeoutRunning());
+}
+
 namespace {
 
 class RenderFrameHostImplBrowserTestWithBFCacheAndViewTransition
Loading diff…

Original Bug Report

reported by vm...@google.com

URL spoof via paint-holding on modified initial empty document

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic error in Chromium’s paint-holding mechanism allows a transient cross-origin URL spoof when an opener modifies a popup’s initial empty document. Attacker-controlled content can remain visible for up to 4 seconds while the omnibox shows a new, committed cross-origin URL. This occurs because DOM mutations on the initial document do not clear the ‘initial empty document’ status used for paint-holding exemptions.

Affected files:

  • content/browser/renderer_host/navigator.cc
  • content/browser/renderer_host/frame_tree_node.cc
  • content/browser/renderer_host/render_frame_host_manager.cc
  • content/browser/renderer_host/navigation_controller_impl.cc
  • content/browser/renderer_host/frame_tree.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_widget_host_impl.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Summary

A potential vulnerability in Chromium’s paint-holding mechanism allows for a transient visual URL spoof. When a window is opened via window.open(), the opener can modify the initial about:blank document (e.g., via innerHTML). If the popup is then navigated to a cross-origin URL without user activation, the browser’s paint-holding logic inadvertently allows the attacker-controlled content to remain visible while the omnibox updates to the new, committed URL.

Technical Analysis

The issue stems from how Chromium tracks the state of the initial empty document across two different flags:

  1. FrameTreeNode::is_on_initial_empty_document_: Initialized to true. It is only cleared by document.open() or a non-blank navigation commit.
  2. FrameTree::has_accessed_initial_main_document_: Set to true when the initial document is scripted or modified.

In Navigator::DidNavigate() (content/browser/renderer_host/navigator.cc), the decision to permit paint-holding for a main-frame navigation depends on whether frame_tree_node->is_on_initial_empty_document() was true before the navigation committed. This check is intended to provide smooth transitions for new tabs but fails to account for cases where the ’empty’ document was modified by its opener.

Because direct DOM mutations (like innerHTML) do not clear the is_on_initial_empty_document_ flag, Navigator::DidNavigate grants a paint-holding exemption even if the navigation is cross-origin and lacks user activation. This bypasses the security mitigation kLimitCrossOriginNonActivatedPaintHolding (content/common/features.cc), which is designed to prevent precisely this type of spoofing.

When the navigation commits:

  1. RenderFrameHostManager::CommitPending() captures the modified about:blank surface as ‘fallback content’.
  2. the omnibox updates to the new committed URL.
  3. RenderWidgetHostImpl displays the attacker’s pixels for up to 4 seconds (the kNewContentRenderingDelay timeout) or until the new page renders its first frame.

User input is suppressed during this period via RenderWidgetHostImpl::InitializePaintHolding(true), so the impact is limited to a visual spoof and cannot be used for direct interaction attacks (like clickjacking).

Potential Reproduction Steps

  1. Serve a page on https://attacker.example that opens a popup: const p = window.open();.
  2. Modify the popup content: p.document.body.innerHTML = '<h1>Logged out of bank.example. Re-enter password.</h1>';.
  3. Navigate the popup cross-origin: p.location = 'https://bank.example/login';.
  4. Observe that after the navigation commits, the omnibox shows https://bank.example/login while the content area still shows the attacker’s message.

Suggested Fix

The paint-holding decision in Navigator::DidNavigate() should be updated to ensure that the exemption for the initial empty document is only granted if the document has not been modified. This can be achieved by checking frame_tree.has_accessed_initial_main_document() in addition to is_on_initial_empty_document() when evaluating allow_main_frame_paint_holding.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker