CVE-2026-87432
Overview
Background
- `BeginNavigationParams` / `client_side_redirect_url`
- parameters a renderer sends to start a navigation;
client_side_redirect_urlis populated only when the navigation’s transition type is a client-side redirect and records the URL the redirect started from. - `FilterURL` / `CanRequestURL`
- a
RenderProcessHostcheck that neutralizes URLs a process may not request (e.g. rewriting them to a blocked URL) but which accepts arbitrary ordinary HTTPS URLs from any process. - `ChildProcessSecurityPolicyImpl::HostsOrigin`
- the browser-side authority that records which origins a given renderer process has actually hosted, used to reject origins a process was never associated with.
- Redirect chain / browsing history
- the ordered list of URLs a
FrameNavigationEntryrecords for a navigation, which feeds the user-visible session history.
Root Cause Analysis
In RenderFrameHostImpl’s begin-navigation handling, the browser previously ran only GetProcess()->FilterURL(true, &begin_params->client_side_redirect_url) on the renderer-supplied client_side_redirect_url. Because FilterURL/CanRequestURL accepts arbitrary HTTPS URLs from any process, a compromised renderer could set client_side_redirect_url to a cross-origin URL its process had never hosted, and that value would be accepted and pushed onto the frame’s redirect chain — injecting an unvisited cross-origin entry into the user’s browsing history. The invariant that a process may only assert a client-side-redirect origin it actually hosts was never enforced.
The fix adds VerifyClientSideRedirectUrl, called in place of the bare FilterURL: it still runs FilterURL (killing the renderer if the URL is kBlocked), allows the empty case, then resolves the URL to redirect_origin and requires ChildProcessSecurityPolicyImpl::HostsOrigin to return true for either that origin or its derived opaque origin (covering sandboxed documents); otherwise it reports RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL and terminates the process, and the caller returns early.
client_side_redirect_url with only FilterURL (a can-request check that accepts arbitrary URLs) instead of verifying the process actually hosted the claimed origin; VerifyClientSideRedirectUrl adds that HostsOrigin check and kills spoofing renderers.Attack Path
- Compromise a renderer
An attacker gains code execution in a renderer process and can craft the
BeginNavigationParamssent for a renderer-initiated navigation. - Poison `client_side_redirect_url`
The renderer injects an arbitrary cross-origin URL (e.g.
b.comfrom ana.comprocess, as inBlockCrossOriginClientSideRedirectUrl) intobegin_params->client_side_redirect_url. - Pass the weak filter
Pre-patch,
FilterURLaccepted the ordinary HTTPS URL without checking process ownership, so the value survived into navigation processing. - Inject a history entry The spoofed URL is recorded as the first entry of the frame’s redirect chain, placing an unvisited cross-origin URL into the user’s session history / navigation entry, per the advisory’s described impact.
Impact Assessment
BlockIllegalClientSideRedirectUrl test, privileged/WebUI) URLs into the user’s browsing history via client_side_redirect_url, an incorrect-authorization flaw in the browser process’s navigation path. The precondition is a compromised or malicious renderer able to forge BeginNavigationParams; legitimate cases (an about:blank frame, a sandboxed frame redirecting itself) remain allowed. The advisory reports this as incorrect authorization in Navigation and does not state exploitation in the wild; any further escalation from history poisoning is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
RemoteFrameHostInterceptorcontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/bad_message.htools/metrics/histograms/metadata/stability/enums.xmlcontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/navigation_browsertest.cccontent/browser/renderer_host/ipc_utils.cccontent/browser/security_exploit_browsertest.cccontent/browser/renderer_host/ipc_utils.h
Audit Directions
- `FilterURL` used as an authorization checkAudit callers that run only
FilterURL/CanRequestURLon renderer-supplied URLs and then persist or act on them, since that check does not prove the process is entitled to the URL’s origin. - Renderer-supplied navigation parametersReview other
BeginNavigationParams/CommonNavigationParamsfields (e.g.searchable_form_url) that flow into history, redirect chains, or origins without aHostsOrigin-style ownership check. - Origin ownership vs. can-requestLook for places that need
ChildProcessSecurityPolicyImpl::HostsOrigin(has this process ever hosted the origin) but instead rely on weaker request-time filters, especially for sandboxed/opaque-origin frames.
Patch
From 6c42a0732b89de096f492051e8aa56935f8c40a3 Mon Sep 17 00:00:00 2001
From: Liam Brady <lbrady@google.com>
Date: Tue, 04 Aug 2026 15:20:56 -0700
Subject: [PATCH] Validate client_side_redirect_url origin in BeginNavigation.
A compromised renderer can supply an arbitrary cross-origin URL as
`client_side_redirect_url` in BeginNavigationParams. The browser
previously only ran FilterURL on this parameter, which checks
CanRequestURL and accepts arbitrary HTTPS URLs from any process,
allowing a compromised renderer to inject unvisited cross-origin entries
into the user's browsing history.
This CL strengthens this validation by introducing
`VerifyClientSideRedirectUrl()` to ipc_utils, which verifies via
`ChildProcessSecurityPolicyImpl::HostsOrigin()` that the initiating
process has previously hosted the claimed origin. If validation fails,
the renderer will be killed with a bad message.
Bug: 511820041, 40066983
Change-Id: I5a369f310f2df5b8ba7fdfba105b9aa1ff1149ca
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162305
Reviewed-by: Mark Pearson <mpearson@chromium.org>
Commit-Queue: Liam Brady <lbrady@google.com>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673720}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index aa702a3..d2c6586 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -396,6 +396,7 @@
PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME = 368,
BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME = 369,
RFH_BEGIN_NAVIGATION_NO_INITIATOR_TOKENS = 370,
+ RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL = 371,
// Please add new elements here. The naming convention is abbreviated class
// name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc
index fb99c3c..ee707728 100644
--- a/content/browser/renderer_host/ipc_utils.cc
+++ b/content/browser/renderer_host/ipc_utils.cc
@@ -366,6 +366,46 @@
return true;
}
+bool VerifyClientSideRedirectUrl(const RenderFrameHostImpl& current_rfh,
+ GURL* client_side_redirect_url) {
+ CHECK_CURRENTLY_ON(BrowserThread::UI);
+ CHECK(client_side_redirect_url);
+
+ RenderProcessHost* process = current_rfh.GetProcess();
+ CHECK(process);
+
+ if (process->FilterURL(true, client_side_redirect_url) ==
+ RenderProcessHost::FilterURLResult::kBlocked) {
+ bad_message::ReceivedBadMessage(
+ process, bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL);
+ return false;
+ }
+
+ // `client_side_redirect_url` is only populated if the navigation's transition
+ // type is a client side redirect. For all other renderer-initiated
+ // navigations, it is intentionally empty.
+ if (client_side_redirect_url->is_empty()) {
+ return true;
+ }
+
+ // Verify that `process` has hosted `redirect_origin` either as a standard
+ // tuple origin or as the precursor of an opaque origin (e.g. when the
+ // redirect is initiated by a sandboxed document).
+ url::Origin redirect_origin = url::Origin::Resolve(
+ *client_side_redirect_url, current_rfh.GetLastCommittedOrigin());
+ auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
+ ChildProcessId process_id = process->GetID();
+ if (!policy->HostsOrigin(process_id.GetUnsafeValue(), redirect_origin) &&
+ !policy->HostsOrigin(process_id.GetUnsafeValue(),
+ redirect_origin.DeriveNewOpaqueOrigin())) {
+ bad_message::ReceivedBadMessage(
+ process, bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL);
+ return false;
+ }
+
+ return true;
+}
+
bool VerifyCreateNewWindowParams(const RenderFrameHostImpl& current_rfh,
const mojom::CreateNewWindowParams& params) {
CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M154);
diff --git a/content/browser/renderer_host/ipc_utils.h b/content/browser/renderer_host/ipc_utils.h
index b56bf12..b9ebe5d 100644
--- a/content/browser/renderer_host/ipc_utils.h
+++ b/content/browser/renderer_host/ipc_utils.h
@@ -60,6 +60,20 @@
blink::mojom::CommonNavigationParams* common_params,
std::optional<blink::LocalFrameToken>& initiator_frame_token);
+// Verifies that `client_side_redirect_url` in BeginNavigationParams is valid
+// and can be accessed by `current_rfh`'s process.
+//
+// FilterURL is run on `client_side_redirect_url` as a side effect.
+// Returns true if `client_side_redirect_url` is valid and its origin is hosted
+// by `current_rfh`'s process.
+//
+// Terminates `current_rfh`'s process and returns false if
+// `client_side_redirect_url` is invalid.
+//
+// This function has to be called on the UI thread.
+bool VerifyClientSideRedirectUrl(const RenderFrameHostImpl& current_rfh,
+ GURL* client_side_redirect_url);
+
// Verifies that the CreateNewWindowParams are valid and can be accessed by
// `current_rfh`'s process.
//
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 4653a24e..a484a889 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -11691,9 +11691,11 @@
}
}
- // TODO(crbug.com/40066983): Consider converting these into renderer kills.
GetProcess()->FilterURL(true, &begin_params->searchable_form_url);
- GetProcess()->FilterURL(true, &begin_params->client_side_redirect_url);
+ if (!VerifyClientSideRedirectUrl(*this,
+ &begin_params->client_side_redirect_url)) {
+ return;
+ }
// If the request was for a blob URL, but the validated URL is no longer a
// blob URL, reset the blob_url_token to prevent hitting the ReportBadMessage
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index 205067b8..d8392d15 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -547,6 +547,7 @@
<int value="368" label="PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME"/>
<int value="369" label="BIBI_BIND_VIBRATION_MANAGER_FOR_FENCED_FRAME"/>
<int value="370" label="RFH_BEGIN_NAVIGATION_NO_INITIATOR_TOKENS"/>
+ <int value="371" label="RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL"/>
</enum>
<enum name="BadMessageReasonExtensions">
diff --git a/content/browser/navigation_browsertest.cc b/content/browser/navigation_browsertest.cc
index 69aec3240..f72b543 100644
--- a/content/browser/navigation_browsertest.cc
+++ b/content/browser/navigation_browsertest.cc
@@ -10340,4 +10340,45 @@
EXPECT_FALSE(policy->CanReadFile(process_c, file_path));
}
+// Verify that navigating an about:blank iframe (which sets
+// client_side_redirect_url to about:blank) succeeds.
+IN_PROC_BROWSER_TEST_F(NavigationBrowserTest,
+ AboutBlankFrameClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+
+ // Create an iframe and navigate it to about:blank so that it is a committed,
+ // non-initial document.
+ EXPECT_TRUE(ExecJs(root->current_frame_host(),
+ "var frame = document.createElement('iframe'); "
+ "frame.id = 'child'; "
+ "document.body.appendChild(frame);"));
+ EXPECT_TRUE(
+ NavigateIframeToURL(web_contents, "child", GURL(url::kAboutBlankURL)));
+
+ // Perform a client-side redirect (replacement navigation) from about:blank.
+ GURL target_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+ TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(ExecJs(root->current_frame_host(),
+ JsReplace("document.getElementById('child').contentWindow."
+ "location.replace($1);",
+ target_url)));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+
+ // Check that the client side redirect URL (about:blank) is pushed as the
+ // first URL onto the subframe's redirect chain.
+ NavigationEntryImpl* entry =
+ web_contents->GetController().GetLastCommittedEntry();
+ FrameNavigationEntry* frame_entry = entry->GetFrameEntry(root->child_at(0));
+ ASSERT_TRUE(frame_entry);
+ EXPECT_EQ(frame_entry->redirect_chain().size(), 2u);
+ EXPECT_EQ(frame_entry->redirect_chain()[0], GURL(url::kAboutBlankURL));
+ EXPECT_EQ(frame_entry->redirect_chain()[1], target_url);
+}
+
} // namespace content
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index b33ffd48..2089bf0 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1875,8 +1875,7 @@
};
// Verify that a compromised renderer can't poison client_side_redirect_url
-// with a privileged URL, which could then be loaded via a subsequent
-// "request desktop site" operation.
+// with a privileged URL.
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
BlockIllegalClientSideRedirectUrl) {
GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
@@ -1888,36 +1887,100 @@
GURL webui_url(GetWebUIURL(kChromeUIGpuHost));
ClientSideRedirectUrlReplacer injector(web_contents, webui_url);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ web_contents->GetPrimaryMainFrame()->GetProcess());
+
// Setup the interceptor to inject the WebUI URL into the next
// BeginNavigation's client_side_redirect_url.
injector.Activate();
- // Trigger a normal renderer-initiated navigation to a benign URL. The
- // injector will poison the client_side_redirect_url in that IPC's
- // BeginNavigationParams.
+ // Trigger a normal renderer-initiated navigation. The injector will poison
+ // the client_side_redirect_url in that IPC's BeginNavigationParams.
GURL next_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
- TestNavigationManager nav_manager(web_contents, next_url);
- EXPECT_TRUE(ExecJs(web_contents, JsReplace("location.href = $1;", next_url)));
+ ExecuteScriptAsync(web_contents, JsReplace("location.href = $1;", next_url));
- // Wait for the navigation to finish.
- ASSERT_TRUE(nav_manager.WaitForNavigationFinished());
- EXPECT_TRUE(nav_manager.was_successful());
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
+}
- // At this point, before the fix, the NavigationEntry has saved
- // the WebUI URL as the OriginalRequestURL.
+// Verify that a compromised renderer can't set client_side_redirect_url to a
+// cross-origin web URL that is not hosted by the process.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ BlockCrossOriginClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
- // Simulate the user clicking "Request Desktop Site" or similar,
- // triggering a reload of the original request URL.
- TestNavigationObserver reload_observer(web_contents);
- web_contents->GetController().LoadOriginalRequestURL();
- reload_observer.Wait();
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
- // Ensure that the browser doesn't navigate to the WebUI URL, which should've
- // been filtered out when processing the corresponding BeginNavigation IPC.
- // TODO(crbug.com/40066983): Consider terminating the renderer process
- // instead.
- EXPECT_NE(webui_url, web_contents->GetLastCommittedURL());
- EXPECT_EQ(GURL(kBlockedURL), web_contents->GetLastCommittedURL());
+ GURL cross_origin_url(
+ embedded_test_server()->GetURL("b.com", "/title2.html"));
+ ClientSideRedirectUrlReplacer injector(web_contents, cross_origin_url);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ web_contents->GetPrimaryMainFrame()->GetProcess());
+
+ injector.Activate();
+
+ GURL next_url(embedded_test_server()->GetURL("a.com", "/title3.html"));
+ ExecuteScriptAsync(web_contents, JsReplace("location.href = $1;", next_url));
+
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
+}
+
+// Verify that a sandboxed frame can legitimately perform client-side redirects
+// without being terminated, but is terminated if it attempts to spoof a
+// client_side_redirect_url that it does not host.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedFrameClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+
+ // Create a sandboxed child frame at a.com.
+ GURL child_url(embedded_test_server()->GetURL("a.com", "/title2.html"));
+ std::string js_str = base::StringPrintf(
+ "var frame = document.createElement('iframe'); "
+ "frame.id = 'sandboxed_child'; "
+ "frame.sandbox = 'allow-scripts'; "
+ "frame.src = '%s'; "
+ "document.body.appendChild(frame);",
+ child_url.spec().c_str());
+ EXPECT_TRUE(ExecJs(root->current_frame_host(), js_str));
+ ASSERT_TRUE(WaitForLoadStop(web_contents));
+
+ RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+ ASSERT_TRUE(subframe->IsSandboxed(network::mojom::WebSandboxFlags::kOrigin));
+
+ // Legitimate client-side redirect in sandboxed frame: the sandboxed frame
+ // navigates itself, and Blink populates client_side_redirect_url with
+ // child_url. Verify that the navigation succeeds and the renderer is not
+ // terminated.
+ GURL next_url(embedded_test_server()->GetURL("a.com", "/title3.html"));
+ TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(ExecJs(subframe, JsReplace("location.href = $1;", next_url)));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+
+ // Malicious attempt: a compromised sandboxed renderer attempts to set
+ // client_side_redirect_url to a cross-origin URL.
+ subframe = root->child_at(0)->current_frame_host();
+ GURL cross_origin_url(
+ embedded_test_server()->GetURL("b.com", "/title2.html"));
+ ClientSideRedirectUrlReplacer injector(web_contents, cross_origin_url);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+ injector.Activate();
+
+ GURL final_url(embedded_test_server()->GetURL("a.com", "/empty.html"));
+ ExecuteScriptAsync(subframe, JsReplace("location.href = $1;", final_url));
+
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
}
class RemoteFrameHostInterceptor
Regression Test / PoC
diff --git a/content/browser/navigation_browsertest.cc b/content/browser/navigation_browsertest.cc
index 69aec3240..f72b543 100644
--- a/content/browser/navigation_browsertest.cc
+++ b/content/browser/navigation_browsertest.cc
@@ -10340,4 +10340,45 @@
EXPECT_FALSE(policy->CanReadFile(process_c, file_path));
}
+// Verify that navigating an about:blank iframe (which sets
+// client_side_redirect_url to about:blank) succeeds.
+IN_PROC_BROWSER_TEST_F(NavigationBrowserTest,
+ AboutBlankFrameClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+
+ // Create an iframe and navigate it to about:blank so that it is a committed,
+ // non-initial document.
+ EXPECT_TRUE(ExecJs(root->current_frame_host(),
+ "var frame = document.createElement('iframe'); "
+ "frame.id = 'child'; "
+ "document.body.appendChild(frame);"));
+ EXPECT_TRUE(
+ NavigateIframeToURL(web_contents, "child", GURL(url::kAboutBlankURL)));
+
+ // Perform a client-side redirect (replacement navigation) from about:blank.
+ GURL target_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+ TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(ExecJs(root->current_frame_host(),
+ JsReplace("document.getElementById('child').contentWindow."
+ "location.replace($1);",
+ target_url)));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+
+ // Check that the client side redirect URL (about:blank) is pushed as the
+ // first URL onto the subframe's redirect chain.
+ NavigationEntryImpl* entry =
+ web_contents->GetController().GetLastCommittedEntry();
+ FrameNavigationEntry* frame_entry = entry->GetFrameEntry(root->child_at(0));
+ ASSERT_TRUE(frame_entry);
+ EXPECT_EQ(frame_entry->redirect_chain().size(), 2u);
+ EXPECT_EQ(frame_entry->redirect_chain()[0], GURL(url::kAboutBlankURL));
+ EXPECT_EQ(frame_entry->redirect_chain()[1], target_url);
+}
+
} // namespace content
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index b33ffd48..2089bf0 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1875,8 +1875,7 @@
};
// Verify that a compromised renderer can't poison client_side_redirect_url
-// with a privileged URL, which could then be loaded via a subsequent
-// "request desktop site" operation.
+// with a privileged URL.
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
BlockIllegalClientSideRedirectUrl) {
GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
@@ -1888,36 +1887,100 @@
GURL webui_url(GetWebUIURL(kChromeUIGpuHost));
ClientSideRedirectUrlReplacer injector(web_contents, webui_url);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ web_contents->GetPrimaryMainFrame()->GetProcess());
+
// Setup the interceptor to inject the WebUI URL into the next
// BeginNavigation's client_side_redirect_url.
injector.Activate();
- // Trigger a normal renderer-initiated navigation to a benign URL. The
- // injector will poison the client_side_redirect_url in that IPC's
- // BeginNavigationParams.
+ // Trigger a normal renderer-initiated navigation. The injector will poison
+ // the client_side_redirect_url in that IPC's BeginNavigationParams.
GURL next_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
- TestNavigationManager nav_manager(web_contents, next_url);
- EXPECT_TRUE(ExecJs(web_contents, JsReplace("location.href = $1;", next_url)));
+ ExecuteScriptAsync(web_contents, JsReplace("location.href = $1;", next_url));
- // Wait for the navigation to finish.
- ASSERT_TRUE(nav_manager.WaitForNavigationFinished());
- EXPECT_TRUE(nav_manager.was_successful());
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
+}
- // At this point, before the fix, the NavigationEntry has saved
- // the WebUI URL as the OriginalRequestURL.
+// Verify that a compromised renderer can't set client_side_redirect_url to a
+// cross-origin web URL that is not hosted by the process.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ BlockCrossOriginClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
- // Simulate the user clicking "Request Desktop Site" or similar,
- // triggering a reload of the original request URL.
- TestNavigationObserver reload_observer(web_contents);
- web_contents->GetController().LoadOriginalRequestURL();
- reload_observer.Wait();
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
- // Ensure that the browser doesn't navigate to the WebUI URL, which should've
- // been filtered out when processing the corresponding BeginNavigation IPC.
- // TODO(crbug.com/40066983): Consider terminating the renderer process
- // instead.
- EXPECT_NE(webui_url, web_contents->GetLastCommittedURL());
- EXPECT_EQ(GURL(kBlockedURL), web_contents->GetLastCommittedURL());
+ GURL cross_origin_url(
+ embedded_test_server()->GetURL("b.com", "/title2.html"));
+ ClientSideRedirectUrlReplacer injector(web_contents, cross_origin_url);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(
+ web_contents->GetPrimaryMainFrame()->GetProcess());
+
+ injector.Activate();
+
+ GURL next_url(embedded_test_server()->GetURL("a.com", "/title3.html"));
+ ExecuteScriptAsync(web_contents, JsReplace("location.href = $1;", next_url));
+
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
+}
+
+// Verify that a sandboxed frame can legitimately perform client-side redirects
+// without being terminated, but is terminated if it attempts to spoof a
+// client_side_redirect_url that it does not host.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ SandboxedFrameClientSideRedirectUrl) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+
+ // Create a sandboxed child frame at a.com.
+ GURL child_url(embedded_test_server()->GetURL("a.com", "/title2.html"));
+ std::string js_str = base::StringPrintf(
+ "var frame = document.createElement('iframe'); "
+ "frame.id = 'sandboxed_child'; "
+ "frame.sandbox = 'allow-scripts'; "
+ "frame.src = '%s'; "
+ "document.body.appendChild(frame);",
+ child_url.spec().c_str());
+ EXPECT_TRUE(ExecJs(root->current_frame_host(), js_str));
+ ASSERT_TRUE(WaitForLoadStop(web_contents));
+
+ RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+ ASSERT_TRUE(subframe->IsSandboxed(network::mojom::WebSandboxFlags::kOrigin));
+
+ // Legitimate client-side redirect in sandboxed frame: the sandboxed frame
+ // navigates itself, and Blink populates client_side_redirect_url with
+ // child_url. Verify that the navigation succeeds and the renderer is not
+ // terminated.
+ GURL next_url(embedded_test_server()->GetURL("a.com", "/title3.html"));
+ TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(ExecJs(subframe, JsReplace("location.href = $1;", next_url)));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+
+ // Malicious attempt: a compromised sandboxed renderer attempts to set
+ // client_side_redirect_url to a cross-origin URL.
+ subframe = root->child_at(0)->current_frame_host();
+ GURL cross_origin_url(
+ embedded_test_server()->GetURL("b.com", "/title2.html"));
+ ClientSideRedirectUrlReplacer injector(web_contents, cross_origin_url);
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+ injector.Activate();
+
+ GURL final_url(embedded_test_server()->GetURL("a.com", "/empty.html"));
+ ExecuteScriptAsync(subframe, JsReplace("location.href = $1;", final_url));
+
+ EXPECT_EQ(bad_message::RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL,
+ kill_waiter.Wait());
}
class RemoteFrameHostInterceptor
Original Bug Report
History Injection of Arbitrary Cross-Origin URLs via client_side_redirect_url
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 https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A compromised renderer process can inject arbitrary cross-origin URLs into a user’s browsing history by supplying a forged client_side_redirect_url in the BeginNavigation IPC. By spoofing the navigation transition type, the attacker can bypass history logic that normally removes redirect source URLs, polluting the history database and enabling UI spoofing.
Affected files:
content/browser/renderer_host/navigation_request.cccomponents/history/core/browser/history_backend.cccontent/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2026-03-31
Summary
A vulnerability in Chrome’s navigation and history handling allows a compromised renderer process to inject arbitrary cross-origin URLs into a user’s browsing history. When processing a BeginNavigation IPC, the browser blindly trusts a renderer-supplied client_side_redirect_url as long as it uses a web-safe scheme. By omitting the PAGE_TRANSITION_CLIENT_REDIRECT qualifier in the transition type, the attacker can bypass the HistoryBackend logic intended to strip client redirect sources, resulting in the forged URL being recorded as a legitimate visit.
Technical Details
- When a renderer initiates a navigation, it sends a
FrameHost.BeginNavigationIPC. A compromised renderer can populatebegin_params.client_side_redirect_urlwith an arbitrary cross-origin URL (e.g.,https://victim.example). - In
RenderFrameHostImpl::BeginNavigation,GetProcess()->FilterURL(true, &begin_params->client_side_redirect_url)is called to sanitize the URL. Because the attacker’s URL uses thehttpsscheme (a web-safe scheme),ChildProcessSecurityPolicyImpl::CanRequestURLreturns true immediately, without enforcing any origin-bound or Site Isolation constraints. - The
NavigationRequestconstructor stores the unvalidated URL. Later,NavigationRequest::StartNavigationunconditionally pushes this URL to the beginning of theredirect_chain_if it is not empty. - Upon navigation commit,
HistoryTabHelper::UpdateHistoryForNavigationpasses theredirect_chain_to the history service. - In
HistoryBackend::AddPage, the logic attempts to remove the first URL from the redirect chain if the navigation was a client redirect (checked viarequest_transition & ui::PAGE_TRANSITION_CLIENT_REDIRECT). - Because the attacker can control the transition type in the IPC (and
PAGE_TRANSITION_LINKis allowed byPageTransitionIsWebTriggerable), they can omit thePAGE_TRANSITION_CLIENT_REDIRECTqualifier. This bypasses the stripping logic. - The
HistoryBackendthen records a visit for the forged URL. Furthermore, because the entire redirect chain is cached inrecent_redirects_, if the attacker’s page subsequently executesdocument.title = "Fake Title",HistoryBackend::SetPageTitleapplies this spoofed title to all URLs in the chain, including the forged cross-origin entry.
Impact
An attacker can pollute the user’s local history database with arbitrary URLs and control the associated page titles. This influences security-sensitive UI surfaces such as the Omnibox autocomplete suggestions and the New Tab Page “Most Visited” tiles, enabling persistent UI spoofing and phishing attacks.
Reproduction Steps (Potential)
Note: These steps describe a potential exploit path, as we do not yet have a working proof of concept.
- Compromise a renderer process hosting
https://attacker.example/page. - Trigger a navigation by sending a
FrameHost.BeginNavigationIPC with the following parameters:common_params.url:https://attacker.example/page(or a sub-page)common_params.transition:ui::PAGE_TRANSITION_LINKbegin_params.client_side_redirect_url:https://victim.example/never-visited
- Allow the navigation to commit.
- Open
chrome://historyto observe thathttps://victim.example/never-visitedhas been recorded. - Have the attacker page execute
document.title = "Fake Title". The history entry for the victim URL will update to the fake title.
Suggested Fix
In RenderFrameHostImpl::BeginNavigation (or VerifyBeginNavigationCommonParams), strictly validate begin_params->client_side_redirect_url. The browser process must verify that the client_side_redirect_url matches the initiator_origin or the origin of the current document invoking the navigation, terminating the renderer if the validation fails.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.