CVE-2026-95320
Overview
Background
- `DidBlockNavigation`
- an IPC handler on
RenderFrameHostImplinvoked by a renderer to report that a navigation (typically a redirect without user gesture) was blocked, which surfaces blocked-redirect UI on the tab. - Back/forward cache (bfcache)
- a mechanism that keeps a fully-constructed but non-active document alive after the user navigates away, so its
RenderFrameHostcan still emit in-flight IPCs. - Fenced frame
- an embedded frame with an enforced boundary from its embedder, whose nested documents legitimately trigger navigation-blocking IPCs but must not drive embedder-tab UI.
- `IsActive` / `IsNestedWithinFencedFrame`
RenderFrameHostImplpredicates that report, respectively, whether the frame host belongs to the active/primary document and whether it is nested inside a fenced frame tree.
Root Cause Analysis
RenderFrameHostImpl::DidBlockNavigation accepted the renderer-originated notification unconditionally and forwarded it to delegate_->OnDidBlockNavigation, which drives tab-level blocked-redirect UI keyed off the primary main frame’s committed URL and origin. Because the handler never checked the lifecycle or fenced-frame status of the reporting RenderFrameHost, a notification from a back/forward-cached (non-active) document or from a document IsNestedWithinFencedFrame would race lifecycle transitions or fire legitimately, yet incorrectly surface blocked-redirect UI attributed to the primary tab. The invariant violated is that only the active, non-fenced document should influence primary-tab navigation UI.
The fix adds an early if (!IsActive() || IsNestedWithinFencedFrame()) { return; } guard that silently drops such notifications, matching sibling LocalFrameHost handlers; it also removes the now-unnecessary DCHECK_NE against LifecycleStateImpl::kPrerendering, since non-active documents are filtered before reaching that point.
DidBlockNavigation trusted every renderer notification without confirming the reporting frame was the active, non-fenced document, letting non-active or fenced RenderFrameHosts spoof tab-level UI; the fix gates the handler on IsActive() and !IsNestedWithinFencedFrame().Attack Path
- Set up a fenced frame
An embedder page (or attacker page hosting content) creates a
fencedframeand navigates it to a document under attacker influence, establishing anIsNestedWithinFencedFrameRenderFrameHost. - Trigger a blocked navigation inside the boundary
The nested document performs a navigation that the browser blocks (for example a redirect with no user gesture,
NavigationBlockedReason::kRedirectWithNoUserGesture), causing the renderer to sendDidBlockNavigation. - Cross the boundary into embedder UI
The unguarded handler forwards to
delegate_->OnDidBlockNavigationusing the primary frame’sGetLastCommittedURLandGetLastCommittedOrigin, so blocked-redirect UI appears on the embedder’s primary tab despite originating in the fenced (or back/forward-cached, non-active) frame. - Misattribute UI to the top-level tab (inferred) The resulting spoofed or misattributed security indicator on the primary tab could mislead the user about the tab’s true navigation state. (inferred)
Impact Assessment
DidBlockNavigation. The advisory classifies this as a LogicError (missing authorization in Navigation); it states no exploited-in-the-wild status, and any escalation beyond misattributed navigation UI is not demonstrated by the patch (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
BlockedNavigationDelegatecontent/browser/fenced_frame/fenced_frame_browsertest.cc |
modified | |
FencedFrameBrowserTestBasecontent/browser/fenced_frame/fenced_frame_browsertest.cc |
modified | |
UUIDFrameTreeBrowserTestcontent/browser/fenced_frame/fenced_frame_browsertest.cc |
modified | |
BlockedNavigationDelegatecontent/browser/renderer_host/render_frame_host_impl_browsertest.cc |
modified | |
RenderFrameHostImplPrerenderBrowserTestcontent/browser/renderer_host/render_frame_host_impl_browsertest.cc |
modified |
Files Changed
content/browser/fenced_frame/fenced_frame_browsertest.cccontent/browser/renderer_host/render_frame_host_impl_browsertest.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/public/browser/prerender_web_contents_delegate.cc
Audit Directions
- Renderer IPC handlers lacking lifecycle guardsAudit
RenderFrameHostImplIPC handlers that forward todelegate_for missingIsActive()/IsNestedWithinFencedFrame()/ bfcache checks before affecting primary-tab UI or state. - Fenced-frame boundary leaksLook for handlers that read
GetLastCommittedURL/GetLastCommittedOriginof the primary frame in response to a nested or fencedRenderFrameHost’s notification, crossing the fenced-frame trust boundary. - DCHECK-only invariantsFind places relying on a
DCHECK/NOTREACHEDto assert a frame is not prerendering or non-active, where release builds silently proceed instead of dropping the notification.
Patch
From aa536593b9a6db357c47a9588c150b71137ebe87 Mon Sep 17 00:00:00 2001
From: Arthur Sonzogni <arthursonzogni@chromium.org>
Date: Wed, 26 Aug 2026 16:10:59 -0700
Subject: [PATCH] Ignore DidBlockNavigation from non-active or fenced RFHs
DidBlockNavigation notifications from back/forward cached documents or
fenced frames incorrectly surface blocked-redirect UI on the primary
tab.
Drop these notifications when the RenderFrameHost is not active or is
nested in a fenced frame, matching sibling LocalFrameHost handlers.
Bug: 514072284
Change-Id: I49889f2e4b9fd09bc2d5c115ca9cb5ea5321ba31
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8295459
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Auto-Submit: Arthur Sonzogni <arthursonzogni@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1686761}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 101aff0..ac2c1bf 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -8965,16 +8965,18 @@
void RenderFrameHostImpl::DidBlockNavigation(
const GURL& blocked_url,
blink::mojom::NavigationBlockedReason reason) {
+ // Silently drop notifications from inactive or fenced frames. In-flight IPCs
+ // can race with lifecycle transitions, and subframes in fenced frames trigger
+ // this IPC legitimately without expecting tab-level UI.
+ if (!IsActive() || IsNestedWithinFencedFrame()) {
+ return;
+ }
+
// Do not allow renderers to show off-limits URLs in the blocked dialog.
GURL validated_blocked_url = blocked_url;
RenderProcessHost* process = GetProcess();
process->FilterURL(/*empty_allowed=*/false, &validated_blocked_url);
- // Cross-origin navigations are not allowed in prerendering so we can not
- // reach here while prerendering.
- // TODO(522986874): CHECK-exclusion: Convert to a CHECK once we are confident
- // it won't be triggered.
- DCHECK_NE(lifecycle_state(), LifecycleStateImpl::kPrerendering);
delegate_->OnDidBlockNavigation(validated_blocked_url, GetLastCommittedURL(),
GetLastCommittedOrigin(), reason);
}
diff --git a/content/public/browser/prerender_web_contents_delegate.cc b/content/public/browser/prerender_web_contents_delegate.cc
index 992b4ba9b..f287124 100644
--- a/content/public/browser/prerender_web_contents_delegate.cc
+++ b/content/public/browser/prerender_web_contents_delegate.cc
@@ -112,8 +112,8 @@
const GURL& initiator_url,
const url::Origin& initiator_origin,
blink::mojom::NavigationBlockedReason reason) {
- // DCHECK against LifecycleState in RenderFrameHostImpl::DidBlockNavigation()
- // ensures this is never called during prerendering.
+ // RenderFrameHostImpl::DidBlockNavigation() ignores notifications from
+ // non-active documents, so this is never called during prerendering.
NOTREACHED();
}
diff --git a/content/browser/fenced_frame/fenced_frame_browsertest.cc b/content/browser/fenced_frame/fenced_frame_browsertest.cc
index 8da392c..6a00f26 100644
--- a/content/browser/fenced_frame/fenced_frame_browsertest.cc
+++ b/content/browser/fenced_frame/fenced_frame_browsertest.cc
@@ -112,6 +112,31 @@
return pending_urn.value();
}
+class BlockedNavigationDelegate : public WebContentsDelegate {
+ public:
+ explicit BlockedNavigationDelegate(WebContents* web_contents)
+ : web_contents_(web_contents) {
+ web_contents_->SetDelegate(this);
+ }
+
+ ~BlockedNavigationDelegate() override { web_contents_->SetDelegate(nullptr); }
+
+ void OnDidBlockNavigation(
+ WebContents* web_contents,
+ const GURL& blocked_url,
+ const GURL& initiator_url,
+ const url::Origin& initiator_origin,
+ blink::mojom::NavigationBlockedReason reason) override {
+ ++blocked_navigation_count_;
+ }
+
+ int blocked_navigation_count() const { return blocked_navigation_count_; }
+
+ private:
+ raw_ptr<WebContents> web_contents_;
+ int blocked_navigation_count_ = 0;
+};
+
} // namespace
class FencedFrameBrowserTestBase : public ContentBrowserTest {
@@ -5588,6 +5613,45 @@
}
}
+// Tests that a fenced frame reporting a blocked redirect does not surface
+// blocked-redirect UI on the embedder's tab.
+IN_PROC_BROWSER_TEST_F(FencedFrameParameterizedBrowserTest,
+ DidBlockNavigationIgnoredFromFencedFrame) {
+ GURL main_url(https_server()->GetURL("a.test", "/hello.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ FrameTreeNode* root = web_contents()->GetPrimaryFrameTree().root();
+
+ EXPECT_TRUE(ExecJs(root,
+ "var fenced_frame = document.createElement('fencedframe');"
+ "document.body.appendChild(fenced_frame);"));
+ ASSERT_EQ(1U, root->child_count());
+ FrameTreeNode* fenced_frame_root_node =
+ GetFencedFrameRootNode(root->child_at(0));
+
+ GURL fenced_frame_url(
+ https_server()->GetURL("a.test", "/fenced_frames/title1.html"));
+ std::string navigate_script =
+ JsReplace("fenced_frame.config = new FencedFrameConfig($1);",
+ fenced_frame_url.spec());
+ NavigateFrameInsideFencedFrameTreeAndWaitForFinishedLoad(
+ fenced_frame_root_node, navigate_script);
+
+ BlockedNavigationDelegate delegate(web_contents());
+
+ // Simulate the fenced frame document reporting a blocked redirect.
+ fenced_frame_root_node->current_frame_host()->DidBlockNavigation(
+ main_url,
+ blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(0, delegate.blocked_navigation_count());
+
+ // The embedder's primary main frame should still be able to report blocked
+ // redirects.
+ primary_main_frame_host()->DidBlockNavigation(
+ main_url,
+ blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(1, delegate.blocked_navigation_count());
+}
+
// Parameterized on whether the feature is enabled or not.
class UUIDFrameTreeBrowserTest
: public FencedFrameBrowserTestBase,
diff --git a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
index 6390b29..b6f5dde4 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -214,6 +214,31 @@
trustmeifembeddingsecure_factory_;
};
+class BlockedNavigationDelegate : public WebContentsDelegate {
+ public:
+ explicit BlockedNavigationDelegate(WebContents* web_contents)
+ : web_contents_(web_contents) {
+ web_contents_->SetDelegate(this);
+ }
+
+ ~BlockedNavigationDelegate() override { web_contents_->SetDelegate(nullptr); }
+
+ void OnDidBlockNavigation(
+ WebContents* web_contents,
+ const GURL& blocked_url,
+ const GURL& initiator_url,
+ const url::Origin& initiator_origin,
+ blink::mojom::NavigationBlockedReason reason) override {
+ ++blocked_navigation_count_;
+ }
+
+ int blocked_navigation_count() const { return blocked_navigation_count_; }
+
+ private:
+ raw_ptr<WebContents> web_contents_;
+ int blocked_navigation_count_ = 0;
+};
+
} // namespace
// TODO(mlamouri): part of these tests were removed because they were dependent
@@ -10087,6 +10112,36 @@
EXPECT_TRUE(ContainsSurfaceIdOrNewer(ids, id_b));
}
+// Tests that DidBlockNavigation from a document that has been placed in the
+// back/forward cache does not surface blocked-redirect UI for the now-primary
+// page.
+IN_PROC_BROWSER_TEST_F(RenderFrameHostImplBrowserTestWithBFCache,
+ DidBlockNavigationIgnoredFromBackForwardCache) {
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+ // Navigate to A.
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImpl* rfh_a = web_contents()->GetPrimaryMainFrame();
+
+ // Navigate to B, placing A in the back/forward cache.
+ EXPECT_TRUE(NavigateToURL(shell(), url_b));
+ ASSERT_TRUE(rfh_a->IsInBackForwardCache());
+
+ BlockedNavigationDelegate delegate(web_contents());
+
+ // Simulate the cached document reporting a blocked redirect after it has
+ // entered the back/forward cache.
+ rfh_a->DidBlockNavigation(
+ url_a, blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(0, delegate.blocked_navigation_count());
+
+ // The active document should still be able to report blocked redirects.
+ web_contents()->GetPrimaryMainFrame()->DidBlockNavigation(
+ url_b, blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(1, delegate.blocked_navigation_count());
+}
+
class RenderFrameHostImplPrerenderBrowserTest
: public RenderFrameHostImplBrowserTest {
public:
Regression Test / PoC
diff --git a/content/browser/fenced_frame/fenced_frame_browsertest.cc b/content/browser/fenced_frame/fenced_frame_browsertest.cc
index 8da392c..6a00f26 100644
--- a/content/browser/fenced_frame/fenced_frame_browsertest.cc
+++ b/content/browser/fenced_frame/fenced_frame_browsertest.cc
@@ -112,6 +112,31 @@
return pending_urn.value();
}
+class BlockedNavigationDelegate : public WebContentsDelegate {
+ public:
+ explicit BlockedNavigationDelegate(WebContents* web_contents)
+ : web_contents_(web_contents) {
+ web_contents_->SetDelegate(this);
+ }
+
+ ~BlockedNavigationDelegate() override { web_contents_->SetDelegate(nullptr); }
+
+ void OnDidBlockNavigation(
+ WebContents* web_contents,
+ const GURL& blocked_url,
+ const GURL& initiator_url,
+ const url::Origin& initiator_origin,
+ blink::mojom::NavigationBlockedReason reason) override {
+ ++blocked_navigation_count_;
+ }
+
+ int blocked_navigation_count() const { return blocked_navigation_count_; }
+
+ private:
+ raw_ptr<WebContents> web_contents_;
+ int blocked_navigation_count_ = 0;
+};
+
} // namespace
class FencedFrameBrowserTestBase : public ContentBrowserTest {
@@ -5588,6 +5613,45 @@
}
}
+// Tests that a fenced frame reporting a blocked redirect does not surface
+// blocked-redirect UI on the embedder's tab.
+IN_PROC_BROWSER_TEST_F(FencedFrameParameterizedBrowserTest,
+ DidBlockNavigationIgnoredFromFencedFrame) {
+ GURL main_url(https_server()->GetURL("a.test", "/hello.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+ FrameTreeNode* root = web_contents()->GetPrimaryFrameTree().root();
+
+ EXPECT_TRUE(ExecJs(root,
+ "var fenced_frame = document.createElement('fencedframe');"
+ "document.body.appendChild(fenced_frame);"));
+ ASSERT_EQ(1U, root->child_count());
+ FrameTreeNode* fenced_frame_root_node =
+ GetFencedFrameRootNode(root->child_at(0));
+
+ GURL fenced_frame_url(
+ https_server()->GetURL("a.test", "/fenced_frames/title1.html"));
+ std::string navigate_script =
+ JsReplace("fenced_frame.config = new FencedFrameConfig($1);",
+ fenced_frame_url.spec());
+ NavigateFrameInsideFencedFrameTreeAndWaitForFinishedLoad(
+ fenced_frame_root_node, navigate_script);
+
+ BlockedNavigationDelegate delegate(web_contents());
+
+ // Simulate the fenced frame document reporting a blocked redirect.
+ fenced_frame_root_node->current_frame_host()->DidBlockNavigation(
+ main_url,
+ blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(0, delegate.blocked_navigation_count());
+
+ // The embedder's primary main frame should still be able to report blocked
+ // redirects.
+ primary_main_frame_host()->DidBlockNavigation(
+ main_url,
+ blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(1, delegate.blocked_navigation_count());
+}
+
// Parameterized on whether the feature is enabled or not.
class UUIDFrameTreeBrowserTest
: public FencedFrameBrowserTestBase,
diff --git a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
index 6390b29..b6f5dde4 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -214,6 +214,31 @@
trustmeifembeddingsecure_factory_;
};
+class BlockedNavigationDelegate : public WebContentsDelegate {
+ public:
+ explicit BlockedNavigationDelegate(WebContents* web_contents)
+ : web_contents_(web_contents) {
+ web_contents_->SetDelegate(this);
+ }
+
+ ~BlockedNavigationDelegate() override { web_contents_->SetDelegate(nullptr); }
+
+ void OnDidBlockNavigation(
+ WebContents* web_contents,
+ const GURL& blocked_url,
+ const GURL& initiator_url,
+ const url::Origin& initiator_origin,
+ blink::mojom::NavigationBlockedReason reason) override {
+ ++blocked_navigation_count_;
+ }
+
+ int blocked_navigation_count() const { return blocked_navigation_count_; }
+
+ private:
+ raw_ptr<WebContents> web_contents_;
+ int blocked_navigation_count_ = 0;
+};
+
} // namespace
// TODO(mlamouri): part of these tests were removed because they were dependent
@@ -10087,6 +10112,36 @@
EXPECT_TRUE(ContainsSurfaceIdOrNewer(ids, id_b));
}
+// Tests that DidBlockNavigation from a document that has been placed in the
+// back/forward cache does not surface blocked-redirect UI for the now-primary
+// page.
+IN_PROC_BROWSER_TEST_F(RenderFrameHostImplBrowserTestWithBFCache,
+ DidBlockNavigationIgnoredFromBackForwardCache) {
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
+
+ // Navigate to A.
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImpl* rfh_a = web_contents()->GetPrimaryMainFrame();
+
+ // Navigate to B, placing A in the back/forward cache.
+ EXPECT_TRUE(NavigateToURL(shell(), url_b));
+ ASSERT_TRUE(rfh_a->IsInBackForwardCache());
+
+ BlockedNavigationDelegate delegate(web_contents());
+
+ // Simulate the cached document reporting a blocked redirect after it has
+ // entered the back/forward cache.
+ rfh_a->DidBlockNavigation(
+ url_a, blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(0, delegate.blocked_navigation_count());
+
+ // The active document should still be able to report blocked redirects.
+ web_contents()->GetPrimaryMainFrame()->DidBlockNavigation(
+ url_b, blink::mojom::NavigationBlockedReason::kRedirectWithNoUserGesture);
+ EXPECT_EQ(1, delegate.blocked_navigation_count());
+}
+
class RenderFrameHostImplPrerenderBrowserTest
: public RenderFrameHostImplBrowserTest {
public: