CVE-2026-11025
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_frame_host_impl_browsertest.cc
Patch
From 31e678d204cda637f180bb16f19288a74a06ebb9 Mon Sep 17 00:00:00 2001
From: Mike West <mkwst@chromium.org>
Date: Tue, 07 Apr 2026 04:53:12 -0700
Subject: [PATCH] PendingNavigation should own the keep-alive handle.
Otherwise, edge cases can cause the initiator's policies to be lost
during deferred navigations, which can remove certain security-relevant
checks (CSP's `form-action`, for example).
Bug: 497595264
Change-Id: Icd91db362f25f6ce7f141ae04c75520c55ad60cc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7719744
Commit-Queue: Mike West <mkwst@chromium.org>
Reviewed-by: Arthur Sonzogni <arthursonzogni@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1610667}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index ac474e22..3c55e866 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -2209,7 +2209,7 @@
blink::mojom::CommonNavigationParamsPtr common_params_;
blink::mojom::BeginNavigationParamsPtr begin_navigation_params_;
mojo::Remote<blink::mojom::NavigationStateKeepAliveHandle>
- opener_keep_alive_handle_;
+ initiator_navigation_state_keep_alive_handle_;
scoped_refptr<network::SharedURLLoaderFactory> blob_url_loader_factory_;
mojo::PendingAssociatedRemote<mojom::NavigationClient> navigation_client_;
mojo::PendingReceiver<mojom::NavigationRendererCancellationListener>
@@ -2222,6 +2222,8 @@
blink::mojom::BeginNavigationParamsPtr begin_navigation_params,
scoped_refptr<network::SharedURLLoaderFactory> blob_url_loader_factory,
mojo::PendingAssociatedRemote<mojom::NavigationClient> navigation_client,
+ mojo::PendingRemote<blink::mojom::NavigationStateKeepAliveHandle>
+ initiator_navigation_state_keep_alive_handle,
mojo::PendingReceiver<mojom::NavigationRendererCancellationListener>
renderer_cancellation_listener,
mojo::PendingReceiver<
@@ -2235,6 +2237,8 @@
blink::mojom::BeginNavigationParamsPtr begin_navigation_params,
scoped_refptr<network::SharedURLLoaderFactory> blob_url_loader_factory,
mojo::PendingAssociatedRemote<mojom::NavigationClient> navigation_client,
+ mojo::PendingRemote<blink::mojom::NavigationStateKeepAliveHandle>
+ initiator_navigation_state_keep_alive_handle,
mojo::PendingReceiver<mojom::NavigationRendererCancellationListener>
renderer_cancellation_listener,
mojo::PendingReceiver<blink::mojom::NavigationResumeDeferredCommitListener>
@@ -2248,9 +2252,16 @@
std::move(renderer_cancellation_listener)),
deferred_commit_resume_listener_(
std::move(deferred_commit_resume_listener)) {
- if (initiator_frame) {
+ if (initiator_navigation_state_keep_alive_handle) {
+ initiator_navigation_state_keep_alive_handle_.Bind(
+ std::move(initiator_navigation_state_keep_alive_handle));
+ } else if (initiator_frame) {
+ // TODO(500074274): It would be ideal to drop this in favor of something
+ // like `CHECK(!initiator_frame ||
+ // initiator_navigation_state_keep_alive_handle)`.
initiator_frame->IssueKeepAliveHandle(
- opener_keep_alive_handle_.BindNewPipeAndPassReceiver());
+ initiator_navigation_state_keep_alive_handle_
+ .BindNewPipeAndPassReceiver());
}
}
@@ -11653,6 +11664,7 @@
pending_navigate_ = std::make_unique<PendingNavigation>(
std::move(validated_common_params), std::move(begin_params),
std::move(blob_url_loader_factory), std::move(navigation_client),
+ std::move(initiator_navigation_state_keep_alive_handle),
std::move(renderer_cancellation_listener),
std::move(deferred_commit_resume_listener), initiator_frame);
return;
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 7c29e5a..b033c0df 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -10611,4 +10611,73 @@
EXPECT_EQ(allowlist_url, web_contents()->GetLastCommittedURL());
}
+IN_PROC_BROWSER_TEST_F(RenderFrameHostImplBrowserTest,
+ DeferredPopupNavigationPreservesInitiatorPolicies) {
+ // 1. Force WebContents in a new Shell to defer new navigations until the
+ // delegate is set.
+ shell()->set_delay_popup_contents_delegate_for_testing(true);
+
+ // 2. Load a page with an iframe.
+ GURL url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ // 3. Navigate the iframe to a page with CSP form-action 'none'.
+ GURL iframe_url = embedded_test_server()->GetURL(
+ "a.com", "/set-header?Content-Security-Policy: form-action 'none'");
+ EXPECT_TRUE(ExecJs(shell(), JsReplace(R"(
+ let iframe = document.createElement('iframe');
+ iframe.id = 'initiator_iframe';
+ iframe.src = $1;
+ document.body.appendChild(iframe);
+ )",
+ iframe_url)));
+ EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+
+ RenderFrameHostImpl* iframe_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(iframe_rfh);
+
+ // 4. From the iframe, open a popup that will navigate to a same-site URL.
+ // The navigation should be deferred because of step 1.
+ GURL popup_url = embedded_test_server()->GetURL("a.com", "/title2.html");
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(iframe_rfh, JsReplace("window.open($1);", popup_url)));
+ Shell* new_shell = new_shell_observer.GetShell();
+ WebContentsImpl* new_contents =
+ static_cast<WebContentsImpl*>(new_shell->web_contents());
+
+ // The navigation in the new popup should be deferred.
+ EXPECT_TRUE(WaitForLoadStop(new_contents));
+ EXPECT_TRUE(new_contents->GetController().IsInitialBlankNavigation());
+
+ // 5. Detach the initiator iframe.
+ EXPECT_TRUE(ExecJs(shell(), R"(
+ let iframe = document.getElementById('initiator_iframe');
+ iframe.remove();
+ )"));
+
+ // 6. Resume the deferred navigation.
+ new_contents->SetDelegate(new_shell);
+ new_contents->ResumeLoadingCreatedWebContents();
+
+ // 7. Verify that the navigation in the popup inherits the initiator's CSP.
+ // Since form-action is 'none', a form submission should be blocked.
+ EXPECT_TRUE(WaitForLoadStop(new_contents));
+ EXPECT_EQ(popup_url, new_contents->GetLastCommittedURL());
+
+ // Try to submit a form. It should be blocked by CSP if policies were
+ // preserved.
+ EXPECT_TRUE(ExecJs(new_contents, R"(
+ let form = document.createElement('form');
+ form.action = '/title3.html';
+ document.body.appendChild(form);
+ form.submit();
+ )"));
+
+ // If CSP form-action 'none' was preserved, the navigation to title3.html
+ // should not happen, and we should still be on popup_url (or about:blank if
+ // blocked early).
+ EXPECT_EQ(popup_url, new_contents->GetLastCommittedURL());
+}
+
} // namespace content
Regression Test / PoC
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 7c29e5a..b033c0df 100644
--- a/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_impl_browsertest.cc
@@ -10611,4 +10611,73 @@
EXPECT_EQ(allowlist_url, web_contents()->GetLastCommittedURL());
}
+IN_PROC_BROWSER_TEST_F(RenderFrameHostImplBrowserTest,
+ DeferredPopupNavigationPreservesInitiatorPolicies) {
+ // 1. Force WebContents in a new Shell to defer new navigations until the
+ // delegate is set.
+ shell()->set_delay_popup_contents_delegate_for_testing(true);
+
+ // 2. Load a page with an iframe.
+ GURL url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ // 3. Navigate the iframe to a page with CSP form-action 'none'.
+ GURL iframe_url = embedded_test_server()->GetURL(
+ "a.com", "/set-header?Content-Security-Policy: form-action 'none'");
+ EXPECT_TRUE(ExecJs(shell(), JsReplace(R"(
+ let iframe = document.createElement('iframe');
+ iframe.id = 'initiator_iframe';
+ iframe.src = $1;
+ document.body.appendChild(iframe);
+ )",
+ iframe_url)));
+ EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
+
+ RenderFrameHostImpl* iframe_rfh = static_cast<RenderFrameHostImpl*>(
+ ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0));
+ ASSERT_TRUE(iframe_rfh);
+
+ // 4. From the iframe, open a popup that will navigate to a same-site URL.
+ // The navigation should be deferred because of step 1.
+ GURL popup_url = embedded_test_server()->GetURL("a.com", "/title2.html");
+ ShellAddedObserver new_shell_observer;
+ EXPECT_TRUE(ExecJs(iframe_rfh, JsReplace("window.open($1);", popup_url)));
+ Shell* new_shell = new_shell_observer.GetShell();
+ WebContentsImpl* new_contents =
+ static_cast<WebContentsImpl*>(new_shell->web_contents());
+
+ // The navigation in the new popup should be deferred.
+ EXPECT_TRUE(WaitForLoadStop(new_contents));
+ EXPECT_TRUE(new_contents->GetController().IsInitialBlankNavigation());
+
+ // 5. Detach the initiator iframe.
+ EXPECT_TRUE(ExecJs(shell(), R"(
+ let iframe = document.getElementById('initiator_iframe');
+ iframe.remove();
+ )"));
+
+ // 6. Resume the deferred navigation.
+ new_contents->SetDelegate(new_shell);
+ new_contents->ResumeLoadingCreatedWebContents();
+
+ // 7. Verify that the navigation in the popup inherits the initiator's CSP.
+ // Since form-action is 'none', a form submission should be blocked.
+ EXPECT_TRUE(WaitForLoadStop(new_contents));
+ EXPECT_EQ(popup_url, new_contents->GetLastCommittedURL());
+
+ // Try to submit a form. It should be blocked by CSP if policies were
+ // preserved.
+ EXPECT_TRUE(ExecJs(new_contents, R"(
+ let form = document.createElement('form');
+ form.action = '/title3.html';
+ document.body.appendChild(form);
+ form.submit();
+ )"));
+
+ // If CSP form-action 'none' was preserved, the navigation to title3.html
+ // should not happen, and we should still be on popup_url (or about:blank if
+ // blocked early).
+ EXPECT_EQ(popup_url, new_contents->GetLastCommittedURL());
+}
+
} // namespace content
Original Bug Report
CSP and LNA bypass via dropped keep-alive handle in deferred popup navigations
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic flaw in RenderFrameHostImpl::BeginNavigation drops the initiator’s security policy keep-alive handle during deferred navigations, such as Android WebView popups. If the attacker immediately destroys the initiator frame, its policies are permanently lost because the fallback keep-alive mechanism fails. This results in a potential bypass of Content Security Policy (CSP) form-action checks and Local Network Access (LNA) restrictions when the navigation eventually resumes.
Affected files:
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/navigation_request.cccontent/browser/renderer_host/navigation_policy_container_builder.cc
Estimated timestamp from git blame: 2026-01-29
Description
There is a potential vulnerability in how Chrome handles initiator security policies for deferred popup navigations. When an embedder defers the creation of a new window (e.g., Android WebView returning false from ShouldResumeRequestsForCreatedWindow), the browser sets waiting_for_init_ to true for the new RenderFrameHostImpl.
When the renderer initiates a navigation for this deferred window, it sends an IPC to RenderFrameHostImpl::BeginNavigation. Crucially, this IPC includes an initiator_navigation_state_keep_alive_handle designed to keep the initiator’s PolicyContainerHost alive even if the initiator frame is subsequently destroyed.
However, in RenderFrameHostImpl::BeginNavigation, if waiting_for_init_ is true, the code stores the navigation parameters in a PendingNavigation object and returns early. The initiator_navigation_state_keep_alive_handle parameter is dropped.
The PendingNavigation constructor attempts to compensate for this by synchronously issuing a new keep-alive handle: initiator_frame->IssueKeepAliveHandle(...). This relies on resolving the initiator_frame via its frame token. If an attacker triggers the navigation and immediately removes the initiator frame from the DOM, the browser may process the frame detachment before or concurrently with the BeginNavigation IPC. In this scenario, initiator_frame is nullptr, the fallback fails silently, and the PolicyContainerHost is garbage collected.
Impact
When the embedder eventually resumes the deferred window (via ResumeLoadingCreatedWebContents), the NavigationRequest is created but cannot find the initiator’s policies. Because NavigationPolicyContainerBuilder::GetInitiatorPolicies() returns null, two major security mechanisms are bypassed:
- Local Network Access (LNA) Bypass:
NavigationRequest::BuildClientSecurityStateForNavigationFetch()returnsnullptr, meaning the network request proceeds without aClientSecurityState. The Network Service fails to enforce LNA preflight checks for the cross-origin request. - CSP form-action Bypass:
NavigationRequest::CheckCSPDirectives()skips theform-actioncheck entirely because theinitiator_policiesobject is null.
Potential Reproduction Steps
Note: These are suggested steps based on static code analysis. Our tooling agent does not currently have the ability to run code or verify the exploit dynamically.
- An attacker hosts a page on a public IP and a victim loads it in an Android WebView app that supports multiple windows.
- The attacker’s page creates an
iframe. - Inside the
iframe, an attacker script dynamically creates a<form>withtarget="_blank"andactionset to a local network resource (e.g.,http://192.168.1.1/admin). - The script calls
form.submit()and immediately callsiframe.remove()to detach the initiator frame. - The browser defers the popup navigation (
waiting_for_init_is true) and drops the keep-alive handle. Because the frame was detached, the fallback keep-alive handle creation fails. - When the application resumes the popup, the
POSTrequest is sent to the local network device without LNA preflight checks or CSPform-actionenforcement, achieving a CSRF attack.
Suggested Fix
The PendingNavigation struct should be updated to take and store the original mojo::PendingRemote<blink::mojom::NavigationStateKeepAliveHandle> passed into RenderFrameHostImpl::BeginNavigation, rather than discarding it.
By transferring ownership of the provided handle into PendingNavigation, the policy container host will remain alive as long as the deferred navigation is pending, regardless of whether the initiator_frame is destroyed in the meantime.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.