Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Scroll
DescriptionIncorrect authorization in Scroll
ComponentScroll
Sub-componentSttf
Bug ClassAuthorization Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker549911100
Fix commit4e7387c0225a (chromium/src) +178/-3
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
CreditedVu Van Tien (@n0_Be3r)
Reported2026-08-21 (per vendor advisory)
Fix landed2026-08-25 (public git)
Disclosed2026-09-22
Patch gap28 days — fix public in source before the advisory

Background

Scroll-to-text-fragment (STTF)
a navigation feature (#:~:text=) that scrolls to and highlights matching text, gated by security restrictions so it cannot leak cross-origin content.
Trusted initiator
a navigation whose requestor is same-origin with the target document or is browser-initiated, tracked in Blink via DocumentLoader::last_navigation_had_trusted_initiator_.
Text fragment token
a one-shot capability (has_text_fragment_token_) that authorizes a document to process an STTF directive, which may be deferred until related page sets are finalized.
Same-document navigation
a navigation (e.g. history.replaceState, hash change) that updates the document/URL without a full document reload, potentially mutating initiator state while an STTF token is still pending.

Root Cause Analysis

STTF security enforcement in TextFragmentAnchor::CheckSecurityRestrictions consulted DocumentLoader::LastNavigationHadTrustedInitiator() to decide whether cross-origin subframe/window.open restrictions should block the fragment. Because processing of a text fragment token can be delayed until related page sets are finalized, a subsequent same-document navigation (such as a replaceState performed by an untrusted initiator) could overwrite last_navigation_had_trusted_initiator_ before the pending fragment was checked, so the check read the trust status of the latest navigation rather than the one that actually created the fragment token. This violated the invariant that a text fragment must be authorized against the initiator that produced it, allowing an untrusted (cross-origin) initiator’s fragment to be treated as trusted.

The fix introduces a separate text_fragment_token_had_trusted_initiator_ member that is snapshotted at each point the token is generated (params_->has_text_fragment_token, GenerateNewTokenForSameDocument, and the trusted-initiator computation) and plumbs it through WebNavigationParams, so CheckSecurityRestrictions now queries TextFragmentTokenHadTrustedInitiator() — the trust state bound to the fragment’s own initiator. The FrameLoader change additionally avoids prematurely processing a deferred cross-document fragment when a same-document navigation without its own fragment occurs, keeping the deferred token and its bound trust state intact.

Key insight
The single core mistake was authorizing a deferred text fragment against the trust state of the most recent navigation instead of the navigation that created the fragment; the fix binds and carries a per-token text_fragment_token_had_trusted_initiator_ flag so the security check always evaluates the correct initiator.

Attack Path

  1. Create a fragment with an untrusted initiator A cross-origin page navigates a target frame with an STTF directive so that a text fragment token is produced whose real initiator is not trusted.
  2. Force deferral The token’s processing is delayed until related page sets are finalized, leaving the fragment pending while last_navigation_had_trusted_initiator_ can still change.
  3. Overwrite the trust state via same-document navigation A same-document navigation (e.g. replaceState, as exercised by the added scroll-to-text-fragment-security-replacestate WPT tests) updates last_navigation_had_trusted_initiator_ to a trusted value.
  4. Pass the stale security check TextFragmentAnchor::CheckSecurityRestrictions reads the now-trusted LastNavigationHadTrustedInitiator() and lets the previously untrusted fragment through, defeating the cross-origin restriction.
  5. Leak cross-origin content Scrolling/highlighting the matched text discloses whether specific text exists in the target document, an information disclosure across the origin boundary (inferred).

Impact Assessment

An attacker gains an incorrect-authorization bypass of STTF’s cross-origin security restrictions in the Blink renderer, letting an untrusted (e.g. cross-origin subframe or window.open) initiator have its text fragment treated as trusted; the precondition is orchestrating a deferred fragment token followed by a same-document navigation that mutates the trusted-initiator state. This can be leveraged to infer the presence of specific text in a cross-origin document (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/loader/document_loader.cc
modified
if
third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
modified

Files Changed

  • third_party/blink/public/web/web_navigation_params.h
  • third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
  • 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/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html
  • third_party/blink/renderer/core/loader/frame_loader.cc

Audit Directions

  • Deferred capability checks
    Audit any security decision made against mutable per-DocumentLoader/per-frame state (like last_navigation_had_trusted_initiator_) when the authorized action can be deferred, since intervening navigations can desynchronize the checked state from the state at authorization time.
  • Same-document navigation state mutation
    Review features that survive across history.replaceState/hash-change same-document navigations for fields that get overwritten but are read later as if still describing the original navigation.
  • Initiator/origin trust plumbing
    Trace every consumer of trusted-initiator and requestor-origin flags through WebNavigationParams to confirm each snapshots trust at the moment a capability (token) is minted rather than re-reading a shared “latest navigation” value.
From 4e7387c0225a714c92d95301f4983d1c7c0d9abb Mon Sep 17 00:00:00 2001
From: Vladimir Levin <vmpstr@chromium.org>
Date: Tue, 25 Aug 2026 12:22:43 -0700
Subject: [PATCH] sttf: Bind the trusted initiator to the initiator creating the fragment

When we create a scroll to text fragment, we might delay processing it
until related page sets are finalized. Meanwhile, we may do a same
doc navigation. Ensure that when we check security restrictions, we
check whether the initiator is trusted using the initiator that created
the fragment, not the latest initiator which may be the wrong thing to
use.

R=bokan@chromium.org

Bug: 549911100
Change-Id: Iabf8796c38576c4bc17549e6c8a1509d5751b731
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8286565
Reviewed-by: David Bokan <bokan@chromium.org>
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Vladimir Levin <vmpstr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1685838}
---
diff --git a/third_party/blink/public/web/web_navigation_params.h b/third_party/blink/public/web/web_navigation_params.h
index e265f408e..cc146441 100644
--- a/third_party/blink/public/web/web_navigation_params.h
+++ b/third_party/blink/public/web/web_navigation_params.h
@@ -460,6 +460,9 @@
   // This is based on a user activation but is different from the above bit as
   // it can be propagated across redirects and is consumed on use.
   bool has_text_fragment_token = false;
+  // Whether the navigation that produced the text fragment token was initiated
+  // from the same-origin as the document or was browser-initiated.
+  bool text_fragment_token_had_trusted_initiator = false;
   // Whether this navigation was browser initiated.
   bool is_browser_initiated = false;
   // Whether the document should be able to access local file:// resources.
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 0fb4b09d..7ca5ca91 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
@@ -76,7 +76,7 @@
   // cross origin iframes or window.open.
   if (!frame.Loader()
            .GetDocumentLoader()
-           ->LastNavigationHadTrustedInitiator()) {
+           ->TextFragmentTokenHadTrustedInitiator()) {
     if (frame.Tree().Parent()) {
       TRACE_EVENT_INSTANT("blink", "CheckSecurityRestrictions", "Result",
                           "Cross-Origin Subframe");
diff --git a/third_party/blink/renderer/core/loader/document_loader.cc b/third_party/blink/renderer/core/loader/document_loader.cc
index 6d2e5b9..83030274 100644
--- a/third_party/blink/renderer/core/loader/document_loader.cc
+++ b/third_party/blink/renderer/core/loader/document_loader.cc
@@ -417,6 +417,7 @@
   bool is_browser_initiated;
   bool is_prerendering;
   bool has_text_fragment_token;
+  bool text_fragment_token_had_trusted_initiator;
   std::optional<String> internal_scroll_to_text_fragment;
   bool was_discarded;
   bool loading_main_document_from_mhtml_archive;
@@ -655,6 +656,10 @@
   // consume its token.
   has_text_fragment_token_ = TextFragmentAnchor::GenerateNewToken(*this) ||
                              params_->has_text_fragment_token;
+  if (params_->has_text_fragment_token) {
+    text_fragment_token_had_trusted_initiator_ =
+        params_->text_fragment_token_had_trusted_initiator;
+  }
 
   if (params_->internal_scroll_to_text_fragment) {
     // We store this in a separate member because params_ is cleared after
@@ -789,6 +794,8 @@
   params->should_have_sticky_user_activation =
       frame_->HasStickyUserActivation() && !frame_->IsMainFrame();
   params->has_text_fragment_token = has_text_fragment_token_;
+  params->text_fragment_token_had_trusted_initiator =
+      text_fragment_token_had_trusted_initiator_;
   if (internal_scroll_to_text_fragment_) {
     params->internal_scroll_to_text_fragment =
         WebString(*internal_scroll_to_text_fragment_);
@@ -1143,6 +1150,10 @@
     has_text_fragment_token_ =
         TextFragmentAnchor::GenerateNewTokenForSameDocument(
             *this, type, same_document_navigation_type);
+    if (has_text_fragment_token_) {
+      text_fragment_token_had_trusted_initiator_ =
+          last_navigation_had_trusted_initiator_;
+    }
   }
 
   SetHistoryItemStateForCommit(history_item_.Get(), type,
@@ -3316,6 +3327,10 @@
   // API).
   last_navigation_had_trusted_initiator_ =
       !requestor_origin_ || is_same_origin_initiator;
+  if (has_text_fragment_token_) {
+    text_fragment_token_had_trusted_initiator_ =
+        last_navigation_had_trusted_initiator_;
+  }
 
   // The PaintHolding feature defers compositor commits until content has been
   // painted or 500ms have passed, whichever comes first. We require that this
diff --git a/third_party/blink/renderer/core/loader/document_loader.h b/third_party/blink/renderer/core/loader/document_loader.h
index 97360e7..145c25a 100644
--- a/third_party/blink/renderer/core/loader/document_loader.h
+++ b/third_party/blink/renderer/core/loader/document_loader.h
@@ -389,6 +389,10 @@
     return last_navigation_had_trusted_initiator_;
   }
 
+  bool TextFragmentTokenHadTrustedInitiator() const {
+    return text_fragment_token_had_trusted_initiator_;
+  }
+
   // Called when the URL needs to be updated due to a document.open() call.
   void DidOpenDocumentInputStream(const KURL& url);
 
@@ -818,6 +822,10 @@
   // through a redirect.
   bool has_text_fragment_token_ = false;
 
+  // If true, the navigation that produced the text fragment token was initiated
+  // from the same-origin as the document or was browser-initiated.
+  bool text_fragment_token_had_trusted_initiator_ = false;
+
   // If set, the document should attempt to scroll this text fragment into view
   // upon load, without highlighting it.
   std::optional<String> internal_scroll_to_text_fragment_;
diff --git a/third_party/blink/renderer/core/loader/frame_loader.cc b/third_party/blink/renderer/core/loader/frame_loader.cc
index cd5f06b..83153fc 100644
--- a/third_party/blink/renderer/core/loader/frame_loader.cc
+++ b/third_party/blink/renderer/core/loader/frame_loader.cc
@@ -579,8 +579,21 @@
 
   // We need to scroll to the fragment whether or not a hash change occurred,
   // since the user might have scrolled since the previous navigation.
-  ProcessFragment(url, frame_load_type, kNavigationWithinSameDocument);
-  has_pending_cross_document_fragment_ = false;
+  if (url.HasFragmentIdentifier()) {
+    // If the same-document navigation explicitly navigates to a fragment, abort
+    // any pending cross-document text fragment and process the new fragment
+    // immediately.
+    has_pending_cross_document_fragment_ = false;
+    ProcessFragment(url, frame_load_type, kNavigationWithinSameDocument);
+  } else if (!has_pending_cross_document_fragment_ ||
+             (frame_->View() && frame_->GetPage() &&
+              frame_->GetPage()
+                  ->RelatedPagesMutationFromPreviousPageFinalized())) {
+    // Avoid prematurely processing a deferred cross-document text fragment
+    // until related pages state is finalized.
+    ProcessFragment(url, frame_load_type, kNavigationWithinSameDocument);
+    has_pending_cross_document_fragment_ = false;
+  }
 
   TakeObjectSnapshot();
 }
diff --git a/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
new file mode 100644
index 0000000..ba1609c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Scroll to text fragment security replaceState target</title>
+  <script src="stash.js"></script>
+  <style>
+    body {
+      margin: 0;
+    }
+    .spacer {
+      height: 3000px;
+    }
+    #text {
+      font-size: 24px;
+      background-color: yellow;
+    }
+  </style>
+  <script>
+    const params = new URLSearchParams(location.search);
+    const action = params.get("action");
+    const key = params.get("key");
+
+    if (action === "replaceState-head") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+
+    document.addEventListener("DOMContentLoaded", () => {
+      if (action === "replaceState-domcontentloaded") {
+        history.replaceState(null, "", location.pathname + "?cleaned=1");
+      }
+    });
+
+    function isInView(element) {
+      const rect = element.getBoundingClientRect();
+      return rect.top >= 0 && rect.top <= window.innerHeight
+          && rect.left >= 0 && rect.left <= window.innerWidth;
+    }
+
+    function checkScroll() {
+      let position = 'unknown';
+      if (window.scrollY === 0) {
+        position = 'top';
+      } else if (isInView(document.getElementById('text'))) {
+        position = 'text';
+      }
+
+      const results = {
+        action: action,
+        scrollPosition: position,
+        scrollY: window.scrollY,
+        href: window.location.href
+      };
+      stashResultsThenClose(key, results);
+    }
+
+    window.addEventListener("load", () => {
+      window.requestAnimationFrame(() => {
+        window.requestAnimationFrame(checkScroll);
+      });
+    });
+  </script>
+</head>
+<body>
+  <script>
+    if (action === "replaceState-body-start") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+  </script>
+  <div class="spacer"></div>
+  <p id="text">target text to match</p>
+  <div class="spacer"></div>
+  <script>
+    if (action === "replaceState-body-end") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+  </script>
+</body>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html
new file mode 100644
index 0000000..0f2d6cf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html
@@ -0,0 +1,57 @@
+<!doctype html>
+<title>Scroll to text fragment security with history.replaceState()</title>
+<meta charset=utf-8>
+<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/#check-if-a-text-directive-can-be-scrolled">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/common/utils.js"></script>
+<script src="stash.js"></script>
+<script>
+const crossOriginTarget = "http://{{hosts[alt][www]}}:{{ports[http][0]}}/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html";
+
+// Sanity checks: when noopener is provided, scrolling to text fragment should work as expected.
+for (const testCase of [
+  { action: "none", description: "Sanity check: noopener with no replaceState scrolls to text fragment" },
+  { action: "replaceState-head", description: "Sanity check: noopener with replaceState in head scrolls to text fragment" },
+]) {
+  promise_test(t => new Promise((resolve, reject) => {
+    const key = token();
+    const url = `${crossOriginTarget}?action=${testCase.action}&key=${key}#:~:text=target%20text`;
+
+    test_driver.bless('Open a URL with a text fragment directive with noopener', () => {
+      window.open(url, '_blank', 'noopener');
+    });
+
+    fetchResults(key, resolve, reject);
+  }).then(data => {
+    assert_equals(data.scrollPosition, 'text',
+      `Expected text fragment directive to activate with noopener, but got scrollPosition=${data.scrollPosition} (scrollY=${data.scrollY})`);
+  }), testCase.description);
+}
+
+// Security restrictions: when opened without noopener (opener retained), text fragment directive
+// MUST NOT activate, even if the target document performs same-document navigations like replaceState().
+for (const testCase of [
+  { action: "none", description: "Control: no replaceState, text fragment directive must not activate when opened with window opener" },
+  { action: "replaceState-head", description: "replaceState in head must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-body-start", description: "replaceState at start of body must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-body-end", description: "replaceState at end of body must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-domcontentloaded", description: "replaceState in DOMContentLoaded must not bypass security restriction when opened with window opener" },
+]) {
+  promise_test(t => new Promise((resolve, reject) => {
+    const key = token();
+    const url = `${crossOriginTarget}?action=${testCase.action}&key=${key}#:~:text=target%20text`;
+
+    test_driver.bless('Open a URL with a text fragment directive with opener', () => {
+      window.open(url, '_blank');
+    });
+
+    fetchResults(key, resolve, reject);
+  }).then(data => {
+    assert_equals(data.scrollPosition, 'top',
+      `Expected text fragment directive to not activate when opened with an opener, but got scrollPosition=${data.scrollPosition} (scrollY=${data.scrollY})`);
+  }), testCase.description);
+}
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
new file mode 100644
index 0000000..ba1609c
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Scroll to text fragment security replaceState target</title>
+  <script src="stash.js"></script>
+  <style>
+    body {
+      margin: 0;
+    }
+    .spacer {
+      height: 3000px;
+    }
+    #text {
+      font-size: 24px;
+      background-color: yellow;
+    }
+  </style>
+  <script>
+    const params = new URLSearchParams(location.search);
+    const action = params.get("action");
+    const key = params.get("key");
+
+    if (action === "replaceState-head") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+
+    document.addEventListener("DOMContentLoaded", () => {
+      if (action === "replaceState-domcontentloaded") {
+        history.replaceState(null, "", location.pathname + "?cleaned=1");
+      }
+    });
+
+    function isInView(element) {
+      const rect = element.getBoundingClientRect();
+      return rect.top >= 0 && rect.top <= window.innerHeight
+          && rect.left >= 0 && rect.left <= window.innerWidth;
+    }
+
+    function checkScroll() {
+      let position = 'unknown';
+      if (window.scrollY === 0) {
+        position = 'top';
+      } else if (isInView(document.getElementById('text'))) {
+        position = 'text';
+      }
+
+      const results = {
+        action: action,
+        scrollPosition: position,
+        scrollY: window.scrollY,
+        href: window.location.href
+      };
+      stashResultsThenClose(key, results);
+    }
+
+    window.addEventListener("load", () => {
+      window.requestAnimationFrame(() => {
+        window.requestAnimationFrame(checkScroll);
+      });
+    });
+  </script>
+</head>
+<body>
+  <script>
+    if (action === "replaceState-body-start") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+  </script>
+  <div class="spacer"></div>
+  <p id="text">target text to match</p>
+  <div class="spacer"></div>
+  <script>
+    if (action === "replaceState-body-end") {
+      history.replaceState(null, "", location.pathname + "?cleaned=1");
+    }
+  </script>
+</body>
+</html>
diff --git a/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html
new file mode 100644
index 0000000..0f2d6cf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.html
@@ -0,0 +1,57 @@
+<!doctype html>
+<title>Scroll to text fragment security with history.replaceState()</title>
+<meta charset=utf-8>
+<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/#check-if-a-text-directive-can-be-scrolled">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/common/utils.js"></script>
+<script src="stash.js"></script>
+<script>
+const crossOriginTarget = "http://{{hosts[alt][www]}}:{{ports[http][0]}}/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.html";
+
+// Sanity checks: when noopener is provided, scrolling to text fragment should work as expected.
+for (const testCase of [
+  { action: "none", description: "Sanity check: noopener with no replaceState scrolls to text fragment" },
+  { action: "replaceState-head", description: "Sanity check: noopener with replaceState in head scrolls to text fragment" },
+]) {
+  promise_test(t => new Promise((resolve, reject) => {
+    const key = token();
+    const url = `${crossOriginTarget}?action=${testCase.action}&key=${key}#:~:text=target%20text`;
+
+    test_driver.bless('Open a URL with a text fragment directive with noopener', () => {
+      window.open(url, '_blank', 'noopener');
+    });
+
+    fetchResults(key, resolve, reject);
+  }).then(data => {
+    assert_equals(data.scrollPosition, 'text',
+      `Expected text fragment directive to activate with noopener, but got scrollPosition=${data.scrollPosition} (scrollY=${data.scrollY})`);
+  }), testCase.description);
+}
+
+// Security restrictions: when opened without noopener (opener retained), text fragment directive
+// MUST NOT activate, even if the target document performs same-document navigations like replaceState().
+for (const testCase of [
+  { action: "none", description: "Control: no replaceState, text fragment directive must not activate when opened with window opener" },
+  { action: "replaceState-head", description: "replaceState in head must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-body-start", description: "replaceState at start of body must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-body-end", description: "replaceState at end of body must not bypass security restriction when opened with window opener" },
+  { action: "replaceState-domcontentloaded", description: "replaceState in DOMContentLoaded must not bypass security restriction when opened with window opener" },
+]) {
+  promise_test(t => new Promise((resolve, reject) => {
+    const key = token();
+    const url = `${crossOriginTarget}?action=${testCase.action}&key=${key}#:~:text=target%20text`;
+
+    test_driver.bless('Open a URL with a text fragment directive with opener', () => {
+      window.open(url, '_blank');
+    });
+
+    fetchResults(key, resolve, reject);
+  }).then(data => {
+    assert_equals(data.scrollPosition, 'top',
+      `Expected text fragment directive to not activate when opened with an opener, but got scrollPosition=${data.scrollPosition} (scrollY=${data.scrollY})`);
+  }), testCase.description);
+}
+</script>
Loading diff…

Original Bug Report

reported by ti...@gmail.com

Text fragment scrolls cross-origin despite a retained opener, when the target calls history.replaceState()

Component Blink > Scroll > TextFragment
Chrome version 151.0.7922.173 (Stable) — also reproduced on Chrome for Testing 152.0.7977.54
Operating system Linux x86-64. The defect is platform-independent: Blink-side navigation logic only, no platform code involved.
Attachment poc/ in this directory, or poc.tar.gz. Five files, no build required.
Suggested severity Medium. The security restriction is fully defeated, but exploitation retains the preconditions stated in §7. For reference, the parent bug 457771782 / CVE-2026-13790 was rated High.
Regression Yes. One of the two variants is introduced by the fix for 457771782 (edf09a64a6e30); bisected to the exact build in §6.

1. Summary

A cross-origin text fragment (#:~:text=…) may only scroll when the target document is alone in its browsing context group — that is, when no other window holds a handle that could observe the scroll. Chromium enforces this in TextFragmentAnchor::CheckSecurityRestrictions().

That check is skipped entirely when DocumentLoader::LastNavigationHadTrustedInitiator() is true, and that flag is recomputed on same-document navigations from the initiator of that navigation. So a same-document navigation performed by the target page’s own script removes the guard that was applied because of the attacker’s cross-origin navigation — while the text-fragment token and the already-parsed TextDirective from the attacker’s navigation are both still live.

history.replaceState() is the specific same-document navigation that reaches the scroll path without clearing that token. The result: an attacker who opened the target with window.open() and deliberately kept the opener still gets the text-fragment scroll.

2. Steps to reproduce

No /etc/hosts edit, no build, and no Chrome flags are required. 127.0.0.1 and 127.0.0.2 are different hosts and therefore different origins.

  1. cd poc && python3 serve.py (serves attacker origin http://127.0.0.1:8801 and target origin http://127.0.0.2:8802)
  2. google-chrome --user-data-dir=/tmp/poc-profile http://127.0.0.1:8801/
  3. Click button 1 — Control. A popup opens. Wait ~4 s and read the banner across the top of it.
  4. Close the popup. Click button 2 — replaceState() during parsing. Wait ~4 s, read the banner.
  5. Close the popup. Click button 3 — replaceState() at load + 0 ms. Wait ~4 s, read the banner.

Every button calls window.open(url, '_blank', 'width=…,height=…') — without noopener, so the attacker retains a live WindowProxy to the target in all three cases.

The click must be a real user gesture, because a text fragment only activates on a navigation carrying transient user activation. The PoC therefore uses no automation, no DevTools protocol and no test harness; it is three static pages and one static server.

The three target pages are byte-identical except for one line. The control has none; the other two run the kind of URL cleanup that is extremely common on real sites:

history.replaceState(null, '', location.pathname + '?tag=' + tag + '&utm_cleaned=1');

3. Expected result

The target must not scroll in any of the three cases. This is asserted by Chromium’s own web-platform tests, so “expected” here is not my interpretation:

  • third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security.sub.html — for a cross-origin window.open() without noopener, asserts scrollPosition === 'top' (“Expected window.open() with an opener to not activate text fragment directive”).
  • third_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-cross-window.html — asserts data.did_scroll === false.
  • Spec, check if a text directive can be scrolled: “If document’s browsing context’s group’s browsing context set has length 1, return true. Otherwise, return false.”

4. Actual result

Only the control is blocked. Both history.replaceState() variants scroll to the text.

Button Target page does Expected Actual on 151.0.7922.173
1 Control nothing not scrolled not scrolled ✅
2 history.replaceState() during parsing not scrolled SCROLLED ❌
3 history.replaceState() in setTimeout(…,0) after load not scrolled SCROLLED ❌

Recorded output from that run (poc/poc.log, real mouse clicks on Chrome Stable 151.0.7922.173):

[ATTACKER] who=attacker&event=opened&tag=CONTROL
[ATTACKER] who=target&tag=CONTROL&DID_SCROLL=false&maxScrollY=0&hasOpener=true
[ATTACKER] who=attacker&event=opened&tag=RS_PARSE
[ATTACKER] who=target&tag=RS_PARSE&DID_SCROLL=true&maxScrollY=2803&hasOpener=true
[ATTACKER] who=attacker&event=opened&tag=RS_LOAD
[ATTACKER] who=target&tag=RS_LOAD&DID_SCROLL=true&maxScrollY=2803&hasOpener=true

hasOpener=true on every arm — the disqualifying condition is present throughout, and only the control is refused. Behaviour was identical on every run performed; the same three configurations were also exercised repeatedly across the Chrome builds tabulated in §6.

5. Root cause

Paths and line numbers from chromium/src @ 0cb6bdcf0c4a1.

(a) The restriction has a trusted-initiator escape hatch. third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc:79-90 — both the subframe test (:80) and the browsing-context-group test (:86-87) sit inside if (!…->LastNavigationHadTrustedInitiator()).

(b) A same-document navigation re-points that flag at a different initiator. third_party/blink/renderer/core/loader/document_loader.cc:1120 — inside UpdateForSameDocumentNavigation(), last_navigation_had_trusted_initiator_ is recomputed unconditionally from the same-document navigation’s initiator. When the target navigates itself, that initiator is same-origin, so the flag becomes true.

(c) The attacker’s parsed directives survive that navigation. third_party/blink/renderer/core/fragment_directive/fragment_directive.cc:37-40 — ConsumeFragmentDirective() returns early when the new URL contains no :~:, without clearing directives_. The TextDirective parsed from the attacker’s URL is therefore still present.

(d) replaceState is the one same-document type that does not clear the token. document_loader.cc:1133-1140 — has_text_fragment_token_ is only recomputed for WebFrameLoadType::kStandard, or SameDocumentNavigationType::kFragment / kNavigationApiIntercept. replaceState is kReplaceCurrentItem + kHistoryApi, matches none of the three, and the token is left intact. (This is also why the attacker cannot trigger the condition themselves — see §7.)

(e) The scroll path it reaches was never gated. document_loader.cc:1955 calls FrameLoader::ProcessScrollForSameDocumentNavigation() (frame_loader.cc:569), which calls ProcessFragment() unconditionally and then clears has_pending_cross_document_fragment_ at :583. The fix for 457771782 gated only FrameLoader::FinishedParsing() (frame_loader.cc:500).

CheckSecurityRestrictions() therefore redeems the still-valid token at text_fragment_anchor.cc:53 and returns true with the browsing-context-group test skipped.

6. Regression analysis

Before the fix for 457771782, FinishedParsing() always ran TryCreate(), and CheckSecurityRestrictions() calls ConsumeTextFragmentToken() (text_fragment_anchor.cc:53) before evaluating the restriction — so a blocked attempt still burned the token, and a later replaceState() had nothing to redeem. The fix defers that call (frame_loader.cc:496-507), which keeps the token alive through load.

chromiumdash reports edf09a64a6e30 first shipped in 150.0.7832.0. Bisected against Chrome for Testing builds:

Build Fix present Variant 2 (during parsing) Variant 3 (load + 0 ms)
149.0.7779.0 no SCROLLED not scrolled
150.0.7828.0 no SCROLLED not scrolled
150.0.7831.0 no — not scrolled
150.0.7832.0 yes — first build containing the fix — SCROLLED
151.0.7922.173 (Stable) yes SCROLLED SCROLLED
152.0.7977.54 yes SCROLLED SCROLLED

Variant 2 is a pre-existing hole. Variant 3 is introduced by the fix, widening the window from “script running during parsing” to “anything up to roughly 250 ms after load”. Measured on Stable: replaceState() at 0 ms after load bypasses the restriction; at 250 ms, 1000 ms and 3000 ms it does not.

7. Impact, and its limits

The restriction exists only to prevent cross-origin text-presence side channels, so defeating it is the defect. Two limits are stated here rather than left to be discovered in triage:

  • The attacker cannot force the replaceState(). The target page must call it early in its own lifetime. This is very common in practice — tracking-parameter cleanup, consent managers and SPA routers all do it on load — but it is a precondition, not something the attacker controls. A cross-origin w.location.href = "…#x" will not substitute: that is a kFragment navigation, which does recompute and clear the token (§5d).
  • Reading the result still requires a scroll-observation primitive. With the opener retained the attacker holds a WindowProxy, which is the state Chromium’s threat model treats as disqualifying, but a WindowProxy alone does not expose the scroll offset. Practical observation uses one of the documented primitives (an attacker-controlled subresource or frame on the target page, or a timing channel).

8. Suggested remediation

Described only; no product source was modified during this investigation.

  1. Make the trusted-initiator exemption a property of the navigation that granted the token, not of the most recent same-document navigation — for example latch the initiator trust alongside has_text_fragment_token_ where it is set at document_loader.cc:651, and read the latched value in CheckSecurityRestrictions(). A token minted by a cross-origin navigation would then remain subject to the browsing-context-group test regardless of which navigation redeems it.
  2. Clear has_text_fragment_token_ on every same-document commit that does not itself carry a text directive, including kHistoryApi — i.e. drop the type filter at document_loader.cc:1133.
  3. Gate FrameLoader::ProcessScrollForSameDocumentNavigation() on RelatedPagesMutationFromPreviousPageFinalized() the same way FinishedParsing() now is, so the deferral introduced by the 457771782 fix cannot be short-circuited by a same-document navigation.
  4. Add a WPT covering “target performs a same-document navigation after a cross-origin text-fragment navigation, with an opener retained”.

9. Attachment manifest

poc/serve.py                 two loopback origins + a /log endpoint (writes poc.log)
poc/A/index.html             attacker origin, three buttons; window.open WITHOUT noopener
poc/B/target-control.html    target, no replaceState               -> must not scroll
poc/B/target-parse.html      target, replaceState during parsing   -> scrolls (defect)
poc/B/target-load.html       target, replaceState at load + 0 ms   -> scrolls (defect, regression)
poc/poc.log                  recorded output of the run in §4

Chrome flags used: --user-data-dir (fresh profile), --no-first-run, --no-default-browser-check. Nothing else — in particular no --no-sandbox, no --enable-blink-features, no --disable-web-security, no --host-resolver-rules.

The only instrumentation on the target pages is a banner reporting window.scrollY; it mirrors what Chromium’s own WPT for this feature does and does not affect the behaviour under test.

View on issue tracker