Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Loader
DescriptionIncorrect authorization in Loader
ComponentLoader
Bug ClassLogic Error
Tracker513841856
Fix commitc3c4adcd6451 (chromium/src) +111/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
content/browser/text_fragment_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
content/browser/text_fragment_browsertest.cc
modified

Files Changed

  • content/browser/text_fragment_browsertest.cc
  • third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
  • third_party/blink/renderer/core/loader/document_loader.cc
From c3c4adcd645122ce624b588e5f3609ae77b93f12 Mon Sep 17 00:00:00 2001
From: Keita Suzuki <suzukikeita@chromium.org>
Date: Sun, 05 Jul 2026 22:48:42 -0700
Subject: [PATCH] Preserve initiator origin for intercepted same-document commits

When a same-document fragment navigation is intercepted via
NavigateEvent.intercept(), the resulting commit goes through
RunURLAndHistoryUpdateSteps() which previously always used the target
frame's own origin as the initiator. Thread the original initiator
origin from CommitSameDocumentNavigation() through
NavigateEventDispatchParams so that the intercepted commit reflects who
actually started the navigation.

Also extend the same-document text-fragment token regeneration to cover
kNavigationApiIntercept and teach GenerateNewTokenForSameDocument() to
treat that type the same as a fragment navigation, so an intercepted
navigation computes the same text-fragment token state as the equivalent
un-intercepted one would.

Add a content browsertest covering a cross-origin iframe that calls
window.top.location.replace() with a text directive while the top frame
intercepts the navigation.

TAG=agy
CONV=13ec7a8f-4d73-4c01-b1b7-4d465e984eb3

Bug: 513841856
Change-Id: I007fe1a7ac7132bd6b76348fe870ea416d33c238
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017843
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Keita Suzuki <suzukikeita@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1656984}
---

diff --git a/content/browser/text_fragment_browsertest.cc b/content/browser/text_fragment_browsertest.cc
index c5f91f11..066e390 100644
--- a/content/browser/text_fragment_browsertest.cc
+++ b/content/browser/text_fragment_browsertest.cc
@@ -640,6 +640,82 @@
   }
 }
 
+// Ensure same-document navigation to a text-fragment is blocked when initiated
+// from a different origin and the destination intercepts the navigation via the
+// navigation API.
+IN_PROC_BROWSER_TEST_F(
+    TextFragmentAnchorBrowserTest,
+    SameDocumentScriptNavigationCrossOriginWithNavigationIntercept) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url(embedded_test_server()->GetURL(
+      "a.test", "/scrollable_page_with_content.html"));
+  GURL target_text_url(embedded_test_server()->GetURL(
+      "a.test", "/scrollable_page_with_content.html#:~:text=hidden"));
+  GURL cross_origin_inner_url(
+      embedded_test_server()->GetURL("b.test", "/hello.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+
+  WebContentsImpl* main_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* root = main_contents->GetPrimaryFrameTree().root();
+
+  // Register a navigate handler that intercepts the same-document navigation
+  // and add a hidden=until-found target so we can detect whether the text
+  // directive was processed via the beforematch event.
+  EXPECT_TRUE(ExecJs(main_contents,
+                     R"JS(
+        let target = document.createElement('div');
+        target.hidden = 'until-found';
+        target.textContent = 'hidden text';
+        document.body.appendChild(target);
+        var did_match = false;
+        target.addEventListener('beforematch', () => { did_match = true; });
+        navigation.addEventListener('navigate', e => {
+          if (e.canIntercept) {
+            e.intercept({handler: async () => {}});
+          }
+        });
+      )JS",
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+
+  // Insert a cross-origin iframe from which we'll execute script.
+  {
+    const auto script = JsReplace(
+        R"JS(
+            let f = document.createElement("iframe");
+            f.src=$1;
+            document.body.appendChild(f);
+          )JS",
+        cross_origin_inner_url);
+
+    TestNavigationObserver observer(main_contents);
+    EXPECT_TRUE(ExecJs(main_contents, script, EXECUTE_SCRIPT_NO_USER_GESTURE));
+    observer.Wait();
+    ASSERT_EQ(1u, root->child_count());
+  }
+
+  // Try navigating the top frame to a same-document text fragment from inside
+  // the iframe via location.replace(). The destination page intercepts the
+  // navigation, but the text directive should still be blocked because the
+  // initiator is cross-origin.
+  {
+    TestNavigationObserver observer(main_contents);
+    RenderFrameHostImpl* child_rfh = root->child_at(0)->current_frame_host();
+    EXPECT_TRUE(ExecJs(child_rfh, JsReplace("window.top.location.replace($1);",
+                                            target_text_url)));
+    observer.Wait();
+    EXPECT_EQ(target_text_url, main_contents->GetLastCommittedURL());
+
+    WaitForPageLoad(main_contents);
+    RunUntilInputProcessed(GetWidgetHost());
+    RunUntilInputProcessed(GetWidgetHost());
+    EXPECT_EQ(false, EvalJs(main_contents, "did_match;",
+                            EXECUTE_SCRIPT_NO_USER_GESTURE));
+    EXPECT_DID_SCROLL(false);
+  }
+}
+
 // Test that when ForceLoadAtTop document policy is explicitly turned off,
 // scrolling to a text fragment is allowed.
 IN_PROC_BROWSER_TEST_F(TextFragmentAnchorBrowserTest, EnabledByDocumentPolicy) {
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
index 0d8f696..0fb4b09d 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
@@ -141,9 +141,12 @@
     mojom::blink::SameDocumentNavigationType same_document_navigation_type) {
   if ((load_type != WebFrameLoadType::kStandard &&
        load_type != WebFrameLoadType::kReplaceCurrentItem) ||
-      same_document_navigation_type !=
-          mojom::blink::SameDocumentNavigationType::kFragment)
+      (same_document_navigation_type !=
+           mojom::blink::SameDocumentNavigationType::kFragment &&
+       same_document_navigation_type !=
+           mojom::blink::SameDocumentNavigationType::kNavigationApiIntercept)) {
     return false;
+  }
 
   // Same-document text fragment navigations are allowed only when initiated
   // from the browser process (e.g. typing in the omnibox) or a same-origin
diff --git a/third_party/blink/renderer/core/loader/document_loader.cc b/third_party/blink/renderer/core/loader/document_loader.cc
index 77bd92d..56f7d56 100644
--- a/third_party/blink/renderer/core/loader/document_loader.cc
+++ b/third_party/blink/renderer/core/loader/document_loader.cc
@@ -1028,15 +1028,20 @@
     UserNavigationInvolvement involvement,
     PerformanceTimelineEntryIdInfo interaction_id,
     bool is_browser_initiated,
-    bool is_synchronously_committed) {
-  // We use the security origin of this frame since callers of this method must
-  // already have performed same origin checks.
+    bool is_synchronously_committed,
+    const SecurityOrigin* initiator_origin) {
+  // Unless the caller has explicitly provided an initiator (e.g. when
+  // committing an intercepted navigate event that was started by a different
+  // frame), use the security origin of this frame since callers of this
+  // method must already have performed same origin checks.
   // is_browser_initiated is false and is_synchronously_committed is true
   // because anything invoking this algorithm is a renderer-initiated navigation
   // in this process.
   UpdateForSameDocumentNavigation(
       new_url, history_item, same_document_navigation_type, std::move(data),
-      type, fire_popstate, frame_->DomWindow()->GetSecurityOrigin(),
+      type, fire_popstate,
+      initiator_origin ? initiator_origin
+                       : frame_->DomWindow()->GetSecurityOrigin(),
       is_browser_initiated, is_synchronously_committed,
       LocalFrame::HasTransientUserActivation(frame_), involvement,
       /*has_ua_visual_transition*/ false, should_skip_screenshot,
@@ -1102,7 +1107,12 @@
     http_method_ = http_names::kGET;
     http_body_ = nullptr;
   }
-
+  // Same-document text fragment navigations are restricted to same-origin or
+  // browser-initiated navigations for security. We must use the original
+  // initiator origin (which might be passed from an intercepted navigate event)
+  // rather than the target frame's origin.
+  // See
+  // https://wicg.github.io/scroll-to-text-fragment/#restricting-the-text-fragment
   last_navigation_had_trusted_initiator_ =
       !initiator_origin || (initiator_origin->IsSameOriginWith(
                                 frame_->DomWindow()->GetSecurityOrigin()) &&
@@ -1117,7 +1127,9 @@
   // history API.
   if (type == WebFrameLoadType::kStandard ||
       same_document_navigation_type ==
-          mojom::blink::SameDocumentNavigationType::kFragment) {
+          mojom::blink::SameDocumentNavigationType::kFragment ||
+      same_document_navigation_type ==
+          mojom::blink::SameDocumentNavigationType::kNavigationApiIntercept) {
     has_text_fragment_token_ =
         TextFragmentAnchor::GenerateNewTokenForSameDocument(
             *this, type, same_document_navigation_type);
@@ -1779,6 +1791,7 @@
     params->involvement = involvement;
     params->source_element = source_element;
     params->destination_item = history_item;
+    params->initiator_origin = initiator_origin;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/text_fragment_browsertest.cc b/content/browser/text_fragment_browsertest.cc
index c5f91f11..066e390 100644
--- a/content/browser/text_fragment_browsertest.cc
+++ b/content/browser/text_fragment_browsertest.cc
@@ -640,6 +640,82 @@
   }
 }
 
+// Ensure same-document navigation to a text-fragment is blocked when initiated
+// from a different origin and the destination intercepts the navigation via the
+// navigation API.
+IN_PROC_BROWSER_TEST_F(
+    TextFragmentAnchorBrowserTest,
+    SameDocumentScriptNavigationCrossOriginWithNavigationIntercept) {
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url(embedded_test_server()->GetURL(
+      "a.test", "/scrollable_page_with_content.html"));
+  GURL target_text_url(embedded_test_server()->GetURL(
+      "a.test", "/scrollable_page_with_content.html#:~:text=hidden"));
+  GURL cross_origin_inner_url(
+      embedded_test_server()->GetURL("b.test", "/hello.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+
+  WebContentsImpl* main_contents =
+      static_cast<WebContentsImpl*>(shell()->web_contents());
+  FrameTreeNode* root = main_contents->GetPrimaryFrameTree().root();
+
+  // Register a navigate handler that intercepts the same-document navigation
+  // and add a hidden=until-found target so we can detect whether the text
+  // directive was processed via the beforematch event.
+  EXPECT_TRUE(ExecJs(main_contents,
+                     R"JS(
+        let target = document.createElement('div');
+        target.hidden = 'until-found';
+        target.textContent = 'hidden text';
+        document.body.appendChild(target);
+        var did_match = false;
+        target.addEventListener('beforematch', () => { did_match = true; });
+        navigation.addEventListener('navigate', e => {
+          if (e.canIntercept) {
+            e.intercept({handler: async () => {}});
+          }
+        });
+      )JS",
+                     EXECUTE_SCRIPT_NO_USER_GESTURE));
+
+  // Insert a cross-origin iframe from which we'll execute script.
+  {
+    const auto script = JsReplace(
+        R"JS(
+            let f = document.createElement("iframe");
+            f.src=$1;
+            document.body.appendChild(f);
+          )JS",
+        cross_origin_inner_url);
+
+    TestNavigationObserver observer(main_contents);
+    EXPECT_TRUE(ExecJs(main_contents, script, EXECUTE_SCRIPT_NO_USER_GESTURE));
+    observer.Wait();
+    ASSERT_EQ(1u, root->child_count());
+  }
+
+  // Try navigating the top frame to a same-document text fragment from inside
+  // the iframe via location.replace(). The destination page intercepts the
+  // navigation, but the text directive should still be blocked because the
+  // initiator is cross-origin.
+  {
+    TestNavigationObserver observer(main_contents);
+    RenderFrameHostImpl* child_rfh = root->child_at(0)->current_frame_host();
+    EXPECT_TRUE(ExecJs(child_rfh, JsReplace("window.top.location.replace($1);",
+                                            target_text_url)));
+    observer.Wait();
+    EXPECT_EQ(target_text_url, main_contents->GetLastCommittedURL());
+
+    WaitForPageLoad(main_contents);
+    RunUntilInputProcessed(GetWidgetHost());
+    RunUntilInputProcessed(GetWidgetHost());
+    EXPECT_EQ(false, EvalJs(main_contents, "did_match;",
+                            EXECUTE_SCRIPT_NO_USER_GESTURE));
+    EXPECT_DID_SCROLL(false);
+  }
+}
+
 // Test that when ForceLoadAtTop document policy is explicitly turned off,
 // scrolling to a text fragment is allowed.
 IN_PROC_BROWSER_TEST_F(TextFragmentAnchorBrowserTest, EnabledByDocumentPolicy) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Navigation API intercept() bypass of TextFragment security via initiator laundering

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: The Navigation API’s intercept() method incorrectly ’launders’ a cross-origin initiator’s origin into a trusted same-origin context during same-document navigations. This allows an attacker to bypass Scroll-to-Text-Fragment security restrictions, potentially enabling cross-origin information leaks.

Affected files:

  • third_party/blink/renderer/core/loader/document_loader.cc
  • third_party/blink/renderer/core/navigation_api/navigate_event.cc
  • third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
  • third_party/blink/renderer/core/navigation_api/navigation_api.cc

Estimated timestamp from git blame: 2021-04-23

Summary

A potential vulnerability in Blink’s Navigation API allows a cross-origin attacker to bypass security restrictions for Scroll-to-Text-Fragment (STTF). By using navigation.intercept() during a same-document navigation (e.g., a hash change), the initiator of the navigation is incorrectly treated as same-origin. This ‘initiator laundering’ bypasses the security logic in TextFragmentAnchor, allowing an attacker to trigger text-fragment matching and its associated side effects (like expanding <details> elements) that would normally be blocked for cross-origin initiators.

Technical Details

The vulnerability arises from the interaction between the Navigation API and DocumentLoader during an intercepted same-document navigation:

  1. Initiator Laundering: When a navigation is intercepted via event.intercept(), the commit process eventually calls DocumentLoader::RunURLAndHistoryUpdateSteps. This method invokes UpdateForSameDocumentNavigation but hardcodes the initiator_origin as the frame’s own origin (frame_->DomWindow()->GetSecurityOrigin()), effectively overwriting the actual cross-origin initiator. (See third_party/blink/renderer/core/loader/document_loader.cc:1029).
  2. Trusted Initiator State: As a result, the last_navigation_had_trusted_initiator_ flag in DocumentLoader is set to true. This flag is the primary signal used by TextFragmentAnchor::CheckSecurityRestrictions to determine if cross-origin restrictions (e.g., blocking STTF in cross-origin iframes or popups) should be applied.
  3. Token Persistence: For same-document navigations intercepted by the Navigation API, DocumentLoader::UpdateForSameDocumentNavigation skips the logic that would normally regenerate or clear the has_text_fragment_token_ flag (see document_loader.cc:1108). This allows a token granted during an earlier trusted load (e.g., a user-activated window.open()) to persist and be consumed by the attacker-controlled navigation.
  4. Security Bypass: When the intercept handler resolves, NavigateEvent::ProcessScrollBehavior creates a TextFragmentAnchor. The security checks in TextFragmentAnchor::CheckSecurityRestrictions pass because they find a valid token and a (laundered) trusted initiator.

Impact

While scrolling might be blocked, the side effects of text-fragment matching still occur. These include:

  • Searching the victim’s DOM for attacker-specified text.
  • Automatically expanding <details> elements or hidden=until-found regions containing the text.
  • Firing beforematch events.

An attacker can observe these side effects (e.g., through layout changes or timing attacks) to determine if specific text exists on a cross-origin page, leading to an XS-Leak (Cross-Site Leak).

Potential Reproduction Steps (Suggested)

  1. A victim page registers a navigation listener: navigation.addEventListener('navigate', e => { if (e.canIntercept) e.intercept({handler: async () => {}}); });.
  2. An attacker page opens the victim page in a popup with user activation: const w = window.open('https://victim.example/');. This grants the initial text fragment token.
  3. The attacker triggers a same-document navigation to a text fragment: w.location.replace('https://victim.example/#:~:text=SECRET');.
  4. The victim’s navigate event listener intercepts the navigation.
  5. Due to the laundering bug, the STTF logic executes, revealing whether ‘SECRET’ exists on the page via observable side effects.

Suggested Fix

  1. Preserve Initiator: Modify NavigateEventDispatchParams to store and preserve the original initiator_origin from the navigation request.
  2. Update Commit Logic: Update DocumentLoader::RunURLAndHistoryUpdateSteps to accept an initiator_origin parameter, and ensure the Navigation API passes the preserved origin during commit.
  3. Manage Tokens: Ensure UpdateForSameDocumentNavigation correctly clears or updates has_text_fragment_token_ for all same-document navigation types, including those intercepted by the Navigation API, if they are initiated from a cross-origin context.

Note: These findings and reproduction steps are based on a source code analysis; no automated proof of concept has been executed.

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