CVE-2026-79051
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/text_fragment_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/text_fragment_browsertest.cc |
modified |
Files Changed
content/browser/text_fragment_browsertest.ccthird_party/blink/renderer/core/fragment_directive/text_fragment_anchor.ccthird_party/blink/renderer/core/loader/document_loader.cc
Patch
From c3c4adcd645122ce624b588e5f3609ae77b93f12 Mon Sep 17 00:00:00 2001
From: Keita Suzuki <suzukikeita@chromium.org>
Date: Sun, 05 Jul 2026 22:48:42 -0700
Subject: [PATCH] Preserve initiator origin for intercepted same-document commits
When a same-document fragment navigation is intercepted via
NavigateEvent.intercept(), the resulting commit goes through
RunURLAndHistoryUpdateSteps() which previously always used the target
frame's own origin as the initiator. Thread the original initiator
origin from CommitSameDocumentNavigation() through
NavigateEventDispatchParams so that the intercepted commit reflects who
actually started the navigation.
Also extend the same-document text-fragment token regeneration to cover
kNavigationApiIntercept and teach GenerateNewTokenForSameDocument() to
treat that type the same as a fragment navigation, so an intercepted
navigation computes the same text-fragment token state as the equivalent
un-intercepted one would.
Add a content browsertest covering a cross-origin iframe that calls
window.top.location.replace() with a text directive while the top frame
intercepts the navigation.
TAG=agy
CONV=13ec7a8f-4d73-4c01-b1b7-4d465e984eb3
Bug: 513841856
Change-Id: I007fe1a7ac7132bd6b76348fe870ea416d33c238
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017843
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Keita Suzuki <suzukikeita@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1656984}
---
diff --git a/content/browser/text_fragment_browsertest.cc b/content/browser/text_fragment_browsertest.cc
index c5f91f11..066e390 100644
--- a/content/browser/text_fragment_browsertest.cc
+++ b/content/browser/text_fragment_browsertest.cc
@@ -640,6 +640,82 @@
}
}
+// Ensure same-document navigation to a text-fragment is blocked when initiated
+// from a different origin and the destination intercepts the navigation via the
+// navigation API.
+IN_PROC_BROWSER_TEST_F(
+ TextFragmentAnchorBrowserTest,
+ SameDocumentScriptNavigationCrossOriginWithNavigationIntercept) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url(embedded_test_server()->GetURL(
+ "a.test", "/scrollable_page_with_content.html"));
+ GURL target_text_url(embedded_test_server()->GetURL(
+ "a.test", "/scrollable_page_with_content.html#:~:text=hidden"));
+ GURL cross_origin_inner_url(
+ embedded_test_server()->GetURL("b.test", "/hello.html"));
+
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ WebContentsImpl* main_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = main_contents->GetPrimaryFrameTree().root();
+
+ // Register a navigate handler that intercepts the same-document navigation
+ // and add a hidden=until-found target so we can detect whether the text
+ // directive was processed via the beforematch event.
+ EXPECT_TRUE(ExecJs(main_contents,
+ R"JS(
+ let target = document.createElement('div');
+ target.hidden = 'until-found';
+ target.textContent = 'hidden text';
+ document.body.appendChild(target);
+ var did_match = false;
+ target.addEventListener('beforematch', () => { did_match = true; });
+ navigation.addEventListener('navigate', e => {
+ if (e.canIntercept) {
+ e.intercept({handler: async () => {}});
+ }
+ });
+ )JS",
+ EXECUTE_SCRIPT_NO_USER_GESTURE));
+
+ // Insert a cross-origin iframe from which we'll execute script.
+ {
+ const auto script = JsReplace(
+ R"JS(
+ let f = document.createElement("iframe");
+ f.src=$1;
+ document.body.appendChild(f);
+ )JS",
+ cross_origin_inner_url);
+
+ TestNavigationObserver observer(main_contents);
+ EXPECT_TRUE(ExecJs(main_contents, script, EXECUTE_SCRIPT_NO_USER_GESTURE));
+ observer.Wait();
+ ASSERT_EQ(1u, root->child_count());
+ }
+
+ // Try navigating the top frame to a same-document text fragment from inside
+ // the iframe via location.replace(). The destination page intercepts the
+ // navigation, but the text directive should still be blocked because the
+ // initiator is cross-origin.
+ {
+ TestNavigationObserver observer(main_contents);
+ RenderFrameHostImpl* child_rfh = root->child_at(0)->current_frame_host();
+ EXPECT_TRUE(ExecJs(child_rfh, JsReplace("window.top.location.replace($1);",
+ target_text_url)));
+ observer.Wait();
+ EXPECT_EQ(target_text_url, main_contents->GetLastCommittedURL());
+
+ WaitForPageLoad(main_contents);
+ RunUntilInputProcessed(GetWidgetHost());
+ RunUntilInputProcessed(GetWidgetHost());
+ EXPECT_EQ(false, EvalJs(main_contents, "did_match;",
+ EXECUTE_SCRIPT_NO_USER_GESTURE));
+ EXPECT_DID_SCROLL(false);
+ }
+}
+
// Test that when ForceLoadAtTop document policy is explicitly turned off,
// scrolling to a text fragment is allowed.
IN_PROC_BROWSER_TEST_F(TextFragmentAnchorBrowserTest, EnabledByDocumentPolicy) {
diff --git a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
index 0d8f696..0fb4b09d 100644
--- a/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
+++ b/third_party/blink/renderer/core/fragment_directive/text_fragment_anchor.cc
@@ -141,9 +141,12 @@
mojom::blink::SameDocumentNavigationType same_document_navigation_type) {
if ((load_type != WebFrameLoadType::kStandard &&
load_type != WebFrameLoadType::kReplaceCurrentItem) ||
- same_document_navigation_type !=
- mojom::blink::SameDocumentNavigationType::kFragment)
+ (same_document_navigation_type !=
+ mojom::blink::SameDocumentNavigationType::kFragment &&
+ same_document_navigation_type !=
+ mojom::blink::SameDocumentNavigationType::kNavigationApiIntercept)) {
return false;
+ }
// Same-document text fragment navigations are allowed only when initiated
// from the browser process (e.g. typing in the omnibox) or a same-origin
diff --git a/third_party/blink/renderer/core/loader/document_loader.cc b/third_party/blink/renderer/core/loader/document_loader.cc
index 77bd92d..56f7d56 100644
--- a/third_party/blink/renderer/core/loader/document_loader.cc
+++ b/third_party/blink/renderer/core/loader/document_loader.cc
@@ -1028,15 +1028,20 @@
UserNavigationInvolvement involvement,
PerformanceTimelineEntryIdInfo interaction_id,
bool is_browser_initiated,
- bool is_synchronously_committed) {
- // We use the security origin of this frame since callers of this method must
- // already have performed same origin checks.
+ bool is_synchronously_committed,
+ const SecurityOrigin* initiator_origin) {
+ // Unless the caller has explicitly provided an initiator (e.g. when
+ // committing an intercepted navigate event that was started by a different
+ // frame), use the security origin of this frame since callers of this
+ // method must already have performed same origin checks.
// is_browser_initiated is false and is_synchronously_committed is true
// because anything invoking this algorithm is a renderer-initiated navigation
// in this process.
UpdateForSameDocumentNavigation(
new_url, history_item, same_document_navigation_type, std::move(data),
- type, fire_popstate, frame_->DomWindow()->GetSecurityOrigin(),
+ type, fire_popstate,
+ initiator_origin ? initiator_origin
+ : frame_->DomWindow()->GetSecurityOrigin(),
is_browser_initiated, is_synchronously_committed,
LocalFrame::HasTransientUserActivation(frame_), involvement,
/*has_ua_visual_transition*/ false, should_skip_screenshot,
@@ -1102,7 +1107,12 @@
http_method_ = http_names::kGET;
http_body_ = nullptr;
}
-
+ // Same-document text fragment navigations are restricted to same-origin or
+ // browser-initiated navigations for security. We must use the original
+ // initiator origin (which might be passed from an intercepted navigate event)
+ // rather than the target frame's origin.
+ // See
+ // https://wicg.github.io/scroll-to-text-fragment/#restricting-the-text-fragment
last_navigation_had_trusted_initiator_ =
!initiator_origin || (initiator_origin->IsSameOriginWith(
frame_->DomWindow()->GetSecurityOrigin()) &&
@@ -1117,7 +1127,9 @@
// history API.
if (type == WebFrameLoadType::kStandard ||
same_document_navigation_type ==
- mojom::blink::SameDocumentNavigationType::kFragment) {
+ mojom::blink::SameDocumentNavigationType::kFragment ||
+ same_document_navigation_type ==
+ mojom::blink::SameDocumentNavigationType::kNavigationApiIntercept) {
has_text_fragment_token_ =
TextFragmentAnchor::GenerateNewTokenForSameDocument(
*this, type, same_document_navigation_type);
@@ -1779,6 +1791,7 @@
params->involvement = involvement;
params->source_element = source_element;
params->destination_item = history_item;
+ params->initiator_origin = initiator_origin;
Regression Test / PoC
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) {
Original Bug Report
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.ccthird_party/blink/renderer/core/navigation_api/navigate_event.ccthird_party/blink/renderer/core/fragment_directive/text_fragment_anchor.ccthird_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:
- Initiator Laundering: When a navigation is intercepted via
event.intercept(), the commit process eventually callsDocumentLoader::RunURLAndHistoryUpdateSteps. This method invokesUpdateForSameDocumentNavigationbut hardcodes theinitiator_originas the frame’s own origin (frame_->DomWindow()->GetSecurityOrigin()), effectively overwriting the actual cross-origin initiator. (Seethird_party/blink/renderer/core/loader/document_loader.cc:1029). - Trusted Initiator State: As a result, the
last_navigation_had_trusted_initiator_flag inDocumentLoaderis set totrue. This flag is the primary signal used byTextFragmentAnchor::CheckSecurityRestrictionsto determine if cross-origin restrictions (e.g., blocking STTF in cross-origin iframes or popups) should be applied. - Token Persistence: For same-document navigations intercepted by the Navigation API,
DocumentLoader::UpdateForSameDocumentNavigationskips the logic that would normally regenerate or clear thehas_text_fragment_token_flag (seedocument_loader.cc:1108). This allows a token granted during an earlier trusted load (e.g., a user-activatedwindow.open()) to persist and be consumed by the attacker-controlled navigation. - Security Bypass: When the intercept handler resolves,
NavigateEvent::ProcessScrollBehaviorcreates aTextFragmentAnchor. The security checks inTextFragmentAnchor::CheckSecurityRestrictionspass 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 orhidden=until-foundregions containing the text. - Firing
beforematchevents.
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)
- A victim page registers a navigation listener:
navigation.addEventListener('navigate', e => { if (e.canIntercept) e.intercept({handler: async () => {}}); });. - 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. - The attacker triggers a same-document navigation to a text fragment:
w.location.replace('https://victim.example/#:~:text=SECRET');. - The victim’s
navigateevent listener intercepts the navigation. - Due to the laundering bug, the STTF logic executes, revealing whether ‘SECRET’ exists on the page via observable side effects.
Suggested Fix
- Preserve Initiator: Modify
NavigateEventDispatchParamsto store and preserve the originalinitiator_originfrom the navigation request. - Update Commit Logic: Update
DocumentLoader::RunURLAndHistoryUpdateStepsto accept aninitiator_originparameter, and ensure the Navigation API passes the preserved origin during commit. - Manage Tokens: Ensure
UpdateForSameDocumentNavigationcorrectly clears or updateshas_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.