Medium watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Loader
DescriptionIncorrect authorization in Loader
ComponentLoader
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513841856
Fix commitc3c4adcd6451 (chromium/src) +111/-10
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-07-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

Text fragment (`#:~:text=`)
a fragment directive that asks the browser to find, scroll to, and highlight matching text. It can also reveal hidden=until-found content, which fires beforematch. Same-document text-fragment navigations are only honored when the browser started them or the initiator is same-origin (per the in-code comment and the WICG spec link).
`NavigateEvent.intercept()`
the Navigation API lets a page’s navigate handler take over a navigation and turn it into a same-document commit. In Blink that commit goes through DocumentLoader::RunURLAndHistoryUpdateSteps with SameDocumentNavigationType::kNavigationApiIntercept.
`NavigateEventDispatchParams`
the struct carrying a navigation’s details (URL, destination item, is_browser_initiated, and so on) from the commit path into the NavigateEvent. Before the patch it had no initiator-origin field.
`last_navigation_had_trusted_initiator_` / `has_text_fragment_token_`
DocumentLoader state. The first records whether the last navigation’s initiator was absent or same-origin. The second, computed by TextFragmentAnchor::GenerateNewTokenForSameDocument, decides whether a same-document text directive may be acted on.

Root Cause Analysis

When a same-document navigation was intercepted with NavigateEvent.intercept(), the commit was replayed through DocumentLoader::RunURLAndHistoryUpdateSteps. That function always passed the target frame’s own GetSecurityOrigin() as the initiator to UpdateForSameDocumentNavigation. Its removed comment explains the assumption: “callers of this method must already have performed same origin checks”. The original initiator, which could be a cross-origin iframe calling window.top.location.replace(), was therefore replaced by the target’s own origin, so the initiator-trust check on that path compared the target’s origin with itself and passed. Separately, TextFragmentAnchor::GenerateNewTokenForSameDocument returned false for any type other than kFragment. The DocumentLoader token-regeneration branch also ran only for kStandard or kFragment, so an intercepted kReplaceCurrentItem navigation did not recompute has_text_fragment_token_ at all.

The patch carries the initiator origin through NavigateEventDispatchParams::initiator_origin (set where the dispatch params are built) into RunURLAndHistoryUpdateSteps, which now prefers it over the frame’s origin. It also treats kNavigationApiIntercept like kFragment in both token checks. An intercepted cross-origin navigation now gets the same text-fragment decision as an un-intercepted one: blocked, as the browser test expects (did_match stays false, no scroll).

Key insight
The Navigation API intercept path swapped the real (possibly cross-origin) initiator for the target document’s own origin, so a security check that depends on who started the navigation was evaluated against the wrong party. The fix carries the original initiator_origin through the intercept commit and runs the text-fragment token logic for intercepted navigations.

Attack Path

  1. Victim page intercepts navigations the target page registers a navigation navigate listener that calls e.intercept() for interceptable navigations, a common single-page-app pattern. The browser test sets this up on a.test.
  2. Cross-origin frame navigates the victim an attacker-controlled cross-origin frame (an iframe from b.test in the test) calls window.top.location.replace() with the victim’s URL plus a #:~:text= directive. In the test this script runs with ExecJs’s default user activation, and whether top-navigation restrictions allow it without activation is not shown by the patch.
  3. Intercepted commit trusts the wrong origin the navigation is intercepted and committed through RunURLAndHistoryUpdateSteps with the victim frame’s own origin as initiator. The cross-origin initiator therefore looks same-origin, and the text-fragment token logic is skipped for kNavigationApiIntercept.
  4. Text directive is processed before the fix, the directive could be acted on (reveal of hidden=until-found content via beforematch, scroll to the match), which the cross-origin restriction exists to prevent.
  5. Cross-origin side channel an attacker who can observe whether a scroll or match happened could use this to test whether specific text exists in the victim page, an XS-Leak style oracle (inferred).

Impact Assessment

The advisory describes “Incorrect authorization in Loader” and gives no in-the-wild status. The patch shows a renderer-side (Blink) authorization bypass. A cross-origin page could make a document process a scroll-to-text directive that should only be honored for same-origin or browser-initiated navigations. This is plausibly usable as a cross-origin information leak about the victim page’s text content (inferred). The preconditions are that the victim page intercepts same-document navigations via the Navigation API and that the attacker can navigate the victim’s frame (for example from an embedded cross-origin iframe, with user activation in the shipped test). No compromised renderer is needed.

Changed Functions

FunctionChangeNotes
Element
third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
modified
HistoryItem
third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
modified
SecurityOrigin
third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
modified
SerializedScriptValue
third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
modified
if
content/browser/text_fragment_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
content/browser/text_fragment_browsertest.cc
modified

Files Changed

  • third_party/blink/renderer/core/navigation_api/navigate_event.cc
  • 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.h
  • third_party/blink/renderer/core/loader/document_loader.cc
  • third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h

Audit Directions

  • Initiator replaced by "self" on replay paths
    look for other places where a navigation is deferred, intercepted, or replayed (Navigation API intercept, history.pushState/replaceState funnels, prerender/BFCache activation, navigation.transition commits) and then defaults the initiator, is_browser_initiated, or user-activation state to the target frame’s own values.
  • Type allow-lists that miss new navigation types
    search for checks against SameDocumentNavigationType::kFragment (and similar enums) that predate kNavigationApiIntercept. Each such check may silently skip a security decision for newer navigation kinds.
  • Initiator-gated features
    review other features gated on “same-origin or browser-initiated” (scroll-to-text, :target behaviors, autofocus, download/popup policies) for paths where the initiator origin is not threaded through to the decision point.
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/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;
     params->is_browser_initiated = is_browser_initiated;
     params->has_ua_visual_transition = has_ua_visual_transition;
     params->is_synchronously_committed_same_document =
diff --git a/third_party/blink/renderer/core/loader/document_loader.h b/third_party/blink/renderer/core/loader/document_loader.h
index 3fd7501..e0a7887 100644
--- a/third_party/blink/renderer/core/loader/document_loader.h
+++ b/third_party/blink/renderer/core/loader/document_loader.h
@@ -253,7 +253,8 @@
       PerformanceTimelineEntryIdInfo interaction_id =
           PerformanceTimelineEntryIdInfo::kNone,
       bool is_browser_initiated = false,
-      bool is_synchronously_committed = true);
+      bool is_synchronously_committed = true,
+      const SecurityOrigin* initiator_origin = nullptr);
 
   // |is_synchronously_committed| is described in comment for
   // CommitSameDocumentNavigation.
diff --git a/third_party/blink/renderer/core/navigation_api/navigate_event.cc b/third_party/blink/renderer/core/navigation_api/navigate_event.cc
index 97884cd..dc44705 100644
--- a/third_party/blink/renderer/core/navigation_api/navigate_event.cc
+++ b/third_party/blink/renderer/core/navigation_api/navigate_event.cc
@@ -450,13 +450,19 @@
   // In our implementation, we call the corresponding function anyway, but
   // |type| being a reload type makes it do none of the spec-relevant
   // steps. Instead it does stuff like the loading spinner and use counters.
+
+  // Pass the original initiator origin to ensure that security checks for the
+  // committed navigation (e.g., Scroll-to-Text Fragment restrictions) are
+  // evaluated against the actual initiator, even though the navigation was
+  // intercepted by this document.
   DomWindow()->document()->Loader()->RunURLAndHistoryUpdateSteps(
       dispatch_params_->url, dispatch_params_->destination_item,
       mojom::blink::SameDocumentNavigationType::kNavigationApiIntercept,
       state_object, ToWebFrameLoadType(navigation_type_), fire_popstate,
       dispatch_params_->should_skip_screenshot, dispatch_params_->involvement,
       dispatch_params_->interaction_id, dispatch_params_->is_browser_initiated,
-      dispatch_params_->is_synchronously_committed_same_document);
+      dispatch_params_->is_synchronously_committed_same_document,
+      dispatch_params_->initiator_origin.get());
   if (LocalDOMWindow* window = DomWindow()) {
     window->GetFrame()->Loader().Progress().DidNavigationApiIntercept();
   }
diff --git a/third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h b/third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
index 4dd7efe..e014905f 100644
--- a/third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
+++ b/third_party/blink/renderer/core/navigation_api/navigate_event_dispatch_params.h
@@ -20,6 +20,7 @@
 
 class Element;
 class HistoryItem;
+class SecurityOrigin;
 class SerializedScriptValue;
 
 enum class NavigateEventType {
@@ -44,6 +45,7 @@
   Member<Element> source_element;
   scoped_refptr<SerializedScriptValue> state_object;
   Member<HistoryItem> destination_item;
+  scoped_refptr<const SecurityOrigin> initiator_origin;
   bool is_browser_initiated = false;
   bool has_ua_visual_transition = false;
   bool is_synchronously_committed_same_document = true;
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…

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