CVE-2026-95289
Overview
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.
text_fragment_token_had_trusted_initiator_ flag so the security check always evaluates the correct initiator.Attack Path
- 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.
- 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. - Overwrite the trust state via same-document navigation
A same-document navigation (e.g.
replaceState, as exercised by the addedscroll-to-text-fragment-security-replacestateWPT tests) updateslast_navigation_had_trusted_initiator_to a trusted value. - Pass the stale security check
TextFragmentAnchor::CheckSecurityRestrictionsreads the now-trustedLastNavigationHadTrustedInitiator()and lets the previously untrusted fragment through, defeating the cross-origin restriction. - 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
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
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/loader/document_loader.cc |
modified | |
ifthird_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.hthird_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate-target.htmlthird_party/blink/renderer/core/fragment_directive/text_fragment_anchor.ccthird_party/blink/renderer/core/loader/document_loader.hthird_party/blink/renderer/core/loader/document_loader.ccthird_party/blink/web_tests/external/wpt/scroll-to-text-fragment/scroll-to-text-fragment-security-replacestate.sub.htmlthird_party/blink/renderer/core/loader/frame_loader.cc
Audit Directions
- Deferred capability checksAudit any security decision made against mutable per-
DocumentLoader/per-frame state (likelast_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 mutationReview 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 plumbingTrace every consumer of trusted-initiator and requestor-origin flags through
WebNavigationParamsto confirm each snapshots trust at the moment a capability (token) is minted rather than re-reading a shared “latest navigation” value.
Patch
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>
Regression Test / PoC
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>
Original Bug Report
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.
cd poc && python3 serve.py(serves attacker originhttp://127.0.0.1:8801and target originhttp://127.0.0.2:8802)google-chrome --user-data-dir=/tmp/poc-profile http://127.0.0.1:8801/- Click button 1 — Control. A popup opens. Wait ~4 s and read the banner across the top of it.
- Close the popup. Click button 2 — replaceState() during parsing. Wait ~4 s, read the banner.
- 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-originwindow.open()withoutnoopener, assertsscrollPosition === '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— assertsdata.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-originw.location.href = "…#x"will not substitute: that is akFragmentnavigation, 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 aWindowProxyalone 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.
- 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 atdocument_loader.cc:651, and read the latched value inCheckSecurityRestrictions(). A token minted by a cross-origin navigation would then remain subject to the browsing-context-group test regardless of which navigation redeems it. - Clear
has_text_fragment_token_on every same-document commit that does not itself carry a text directive, includingkHistoryApi— i.e. drop the type filter atdocument_loader.cc:1133. - Gate
FrameLoader::ProcessScrollForSameDocumentNavigation()onRelatedPagesMutationFromPreviousPageFinalized()the same wayFinishedParsing()now is, so the deferral introduced by the 457771782 fix cannot be short-circuited by a same-document navigation. - 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.