CVE-2026-87451
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/navigation_request.cc |
modified |
Files Changed
content/browser/renderer_host/navigation_request.cccontent/browser/renderer_host/navigation_request.hcontent/browser/renderer_host/navigation_request_browsertest.cc
Patch
From 3558bf2bb5f17de8f6a80cad45d7412c7f61e6ea Mon Sep 17 00:00:00 2001
From: Liam Brady <lbrady@google.com>
Date: Tue, 04 Aug 2026 13:34:24 -0700
Subject: [PATCH] Recompute OpenerCrossOrigin download policy in browser.
This CL recomputes the OpenerCrossOrigin download policy inside
NavigationRequest on the browser side. This prevents a compromised
renderer from bypassing cross-origin opener download restrictions by
tampering with IPC parameters.
This CL also sanitizes the URL logged to the console for cross-origin
opener download deprecation to only include the serialized origin,
avoiding leakage of sensitive paths/tokens in query parameters.
Bug: 517178299
Change-Id: I829b4e653c3e0c7f9c6b6c5f35987bb51ce49d31
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8172183
Commit-Queue: Liam Brady <lbrady@google.com>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673634}
---
diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index f48b3b1..bd3801e1 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -1836,6 +1836,15 @@
}
#endif
+ if (GetInitiatorFrameToken().has_value()) {
+ RenderFrameHostImpl* initiator_rfh = RenderFrameHostImpl::FromFrameToken(
+ GetInitiatorProcessId(), GetInitiatorFrameToken().value());
+ if (initiator_rfh) {
+ initiator_document_token_ = initiator_rfh->GetDocumentToken();
+ is_opener_navigation_ =
+ (initiator_rfh->frame_tree_node()->opener() == frame_tree_node_);
+ }
+ }
ComputeDownloadPolicy();
@@ -1847,13 +1856,6 @@
perfetto::protos::pbzero::ChromeTrackEvent::kNavigation, this);
TRACE_EVENT_BEGIN("navigation", "Initializing", GetNavigationTracingTrack());
- if (GetInitiatorFrameToken().has_value()) {
- RenderFrameHostImpl* initiator_rfh = RenderFrameHostImpl::FromFrameToken(
- GetInitiatorProcessId(), GetInitiatorFrameToken().value());
- if (initiator_rfh)
- initiator_document_token_ = initiator_rfh->GetDocumentToken();
- }
-
// Spec: https://github.com/whatwg/html/issues/8846
// We only allow the parent to access a subframe resource timing if the
// navigation is container-initiated, e.g. iframe changed src.
@@ -8649,7 +8651,7 @@
"Navigating a cross-origin opener to a download (%s) is "
"deprecated, see "
"https://www.chromestatus.com/feature/5742188281462784.",
- common_params_->url.spec().c_str()));
+ common_params_->url.DeprecatedGetOriginAsURL().spec().c_str()));
GetContentClient()->browser()->LogWebFeatureForCurrentPage(
rfh, blink::mojom::WebFeature::kOpenerNavigationDownloadCrossOrigin);
}
@@ -12247,12 +12249,21 @@
download_policy().SetDisallowed(blink::NavigationDownloadType::kSandbox);
}
+ // [OpenerCrossOrigin]
+ bool is_cross_origin =
+ GetInitiatorOrigin() && !GetInitiatorOrigin()->IsSameOriginWith(
+ frame_tree_node_->current_origin());
+
+ if (is_opener_navigation_ && is_cross_origin) {
+ download_policy().SetDisallowed(
+ blink::NavigationDownloadType::kOpenerCrossOrigin);
+ }
+
// TODO(arthursonzogni): Check if the following fields from the
// NavigationDownloadPolicy could be computed here from the browser process
// instead:
//
// [NoGesture]
- // [OpenerCrossOrigin]
// [AdFrameNoGesture]
// [AdFrame]
// [Interstitial]
diff --git a/content/browser/renderer_host/navigation_request.h b/content/browser/renderer_host/navigation_request.h
index 2ed8127a..de88c2f 100644
--- a/content/browser/renderer_host/navigation_request.h
+++ b/content/browser/renderer_host/navigation_request.h
@@ -1904,6 +1904,9 @@
private:
friend class NavigationRequestTest;
+ FRIEND_TEST_ALL_PREFIXES(
+ NavigationRequestDownloadBrowserTest,
+ OpenerCrossOrigin_BrowserOverridesCompromisedRenderer);
FRIEND_TEST_ALL_PREFIXES(NavigationRequestTest, SanitizeRedirectsForCommit);
FRIEND_TEST_ALL_PREFIXES(NavigationRequestTest,
SanitizeRedirectsForCommitRelativeLocation);
@@ -3220,6 +3223,10 @@
// initiated navigations which use `CreateBrowserInitiated()`.
const bool was_opener_suppressed_ = false;
+ // Indicates whether the initiator is navigating its opener frame at the time
+ // of request creation.
+ bool is_opener_navigation_ = false;
+
// This tracks a connection between the current pending entry and this
// request, such that the pending entry can be discarded if no requests are
// left referencing it.
diff --git a/content/browser/renderer_host/navigation_request_browsertest.cc b/content/browser/renderer_host/navigation_request_browsertest.cc
index 197641d..ca27378 100644
--- a/content/browser/renderer_host/navigation_request_browsertest.cc
+++ b/content/browser/renderer_host/navigation_request_browsertest.cc
@@ -15,6 +15,7 @@
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/stringprintf.h"
+#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/values.h"
@@ -3826,6 +3827,105 @@
EXPECT_FALSE(handle_observer.is_download());
}
+IN_PROC_BROWSER_TEST_F(NavigationRequestDownloadBrowserTest,
+ OpenerCrossOriginDownload_SanitizesConsoleUrl) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL popup_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+ GURL download_url(embedded_test_server()->GetURL(
+ "b.com", "/download-test1.lib?token=SECRET_12345"));
+
+ ASSERT_TRUE(NavigateToURL(shell(), main_url));
+
+ WebContentsConsoleObserver console_observer(shell()->web_contents());
+ console_observer.SetPattern(
+ "*Navigating a cross-origin opener to a download*");
+
+ // Open a cross-origin popup from a.com.
+ ShellAddedObserver shell_observer;
+ std::string open_script = JsReplace("window.open($1, 'popup');", popup_url);
+ EXPECT_TRUE(ExecJs(shell(), open_script));
+ Shell* popup = shell_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ // Navigate opener (a.com) to cross-origin download from the cross-origin
+ // popup (c.com).
+ std::string script = JsReplace("window.opener.location = $1;", download_url);
+ EXPECT_TRUE(ExecJs(popup->web_contents(), script));
+
+ ASSERT_TRUE(console_observer.Wait());
+ ASSERT_EQ(1u, console_observer.messages().size());
+ std::string msg = base::UTF16ToUTF8(console_observer.messages()[0].message);
+
+ // Verify the console message contains the origin (b.com) but not full path or
+ // secret query token.
+ EXPECT_THAT(msg, ::testing::HasSubstr("b.com"));
+ EXPECT_THAT(msg, ::testing::Not(::testing::HasSubstr("SECRET_12345")));
+ EXPECT_THAT(msg, ::testing::Not(::testing::HasSubstr("download-test1.lib")));
+}
+
+IN_PROC_BROWSER_TEST_F(NavigationRequestDownloadBrowserTest,
+ OpenerCrossOrigin_BrowserOverridesCompromisedRenderer) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL popup_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+ GURL download_url(
+ embedded_test_server()->GetURL("b.com", "/download-test1.lib"));
+
+ ASSERT_TRUE(NavigateToURL(shell(), main_url));
+
+ ShellAddedObserver shell_observer;
+ std::string open_script = JsReplace("window.open($1, 'popup');", popup_url);
+ EXPECT_TRUE(ExecJs(shell(), open_script));
+ Shell* popup = shell_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ RenderFrameHostImpl* main_rfh =
+ static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryMainFrame();
+ RenderFrameHostImpl* popup_rfh =
+ static_cast<WebContentsImpl*>(popup->web_contents())
+ ->GetPrimaryMainFrame();
+
+ EXPECT_NE(nullptr, popup_rfh->frame_tree_node()->opener());
+ EXPECT_EQ(main_rfh->frame_tree_node(),
+ popup_rfh->frame_tree_node()->opener());
+
+ TestNavigationManager manager(shell()->web_contents(), download_url);
+ std::string script = JsReplace("window.opener.location = $1;", download_url);
+ EXPECT_TRUE(ExecJs(popup->web_contents(), script));
+
+ EXPECT_TRUE(manager.WaitForRequestStart());
+ NavigationRequest* request =
+ static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryMainFrame()
Regression Test / PoC
diff --git a/content/browser/renderer_host/navigation_request_browsertest.cc b/content/browser/renderer_host/navigation_request_browsertest.cc
index 197641d..ca27378 100644
--- a/content/browser/renderer_host/navigation_request_browsertest.cc
+++ b/content/browser/renderer_host/navigation_request_browsertest.cc
@@ -15,6 +15,7 @@
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/stringprintf.h"
+#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/values.h"
@@ -3826,6 +3827,105 @@
EXPECT_FALSE(handle_observer.is_download());
}
+IN_PROC_BROWSER_TEST_F(NavigationRequestDownloadBrowserTest,
+ OpenerCrossOriginDownload_SanitizesConsoleUrl) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL popup_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+ GURL download_url(embedded_test_server()->GetURL(
+ "b.com", "/download-test1.lib?token=SECRET_12345"));
+
+ ASSERT_TRUE(NavigateToURL(shell(), main_url));
+
+ WebContentsConsoleObserver console_observer(shell()->web_contents());
+ console_observer.SetPattern(
+ "*Navigating a cross-origin opener to a download*");
+
+ // Open a cross-origin popup from a.com.
+ ShellAddedObserver shell_observer;
+ std::string open_script = JsReplace("window.open($1, 'popup');", popup_url);
+ EXPECT_TRUE(ExecJs(shell(), open_script));
+ Shell* popup = shell_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ // Navigate opener (a.com) to cross-origin download from the cross-origin
+ // popup (c.com).
+ std::string script = JsReplace("window.opener.location = $1;", download_url);
+ EXPECT_TRUE(ExecJs(popup->web_contents(), script));
+
+ ASSERT_TRUE(console_observer.Wait());
+ ASSERT_EQ(1u, console_observer.messages().size());
+ std::string msg = base::UTF16ToUTF8(console_observer.messages()[0].message);
+
+ // Verify the console message contains the origin (b.com) but not full path or
+ // secret query token.
+ EXPECT_THAT(msg, ::testing::HasSubstr("b.com"));
+ EXPECT_THAT(msg, ::testing::Not(::testing::HasSubstr("SECRET_12345")));
+ EXPECT_THAT(msg, ::testing::Not(::testing::HasSubstr("download-test1.lib")));
+}
+
+IN_PROC_BROWSER_TEST_F(NavigationRequestDownloadBrowserTest,
+ OpenerCrossOrigin_BrowserOverridesCompromisedRenderer) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL popup_url(embedded_test_server()->GetURL("c.com", "/title1.html"));
+ GURL download_url(
+ embedded_test_server()->GetURL("b.com", "/download-test1.lib"));
+
+ ASSERT_TRUE(NavigateToURL(shell(), main_url));
+
+ ShellAddedObserver shell_observer;
+ std::string open_script = JsReplace("window.open($1, 'popup');", popup_url);
+ EXPECT_TRUE(ExecJs(shell(), open_script));
+ Shell* popup = shell_observer.GetShell();
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ RenderFrameHostImpl* main_rfh =
+ static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryMainFrame();
+ RenderFrameHostImpl* popup_rfh =
+ static_cast<WebContentsImpl*>(popup->web_contents())
+ ->GetPrimaryMainFrame();
+
+ EXPECT_NE(nullptr, popup_rfh->frame_tree_node()->opener());
+ EXPECT_EQ(main_rfh->frame_tree_node(),
+ popup_rfh->frame_tree_node()->opener());
+
+ TestNavigationManager manager(shell()->web_contents(), download_url);
+ std::string script = JsReplace("window.opener.location = $1;", download_url);
+ EXPECT_TRUE(ExecJs(popup->web_contents(), script));
+
+ EXPECT_TRUE(manager.WaitForRequestStart());
+ NavigationRequest* request =
+ static_cast<WebContentsImpl*>(shell()->web_contents())
+ ->GetPrimaryMainFrame()
+ ->frame_tree_node()
+ ->navigation_request();
+
+ // Simulate a compromised renderer that stripped the kOpenerCrossOrigin flag
+ // from IPC.
+ request->common_params_->download_policy.observed_types.reset();
+ request->common_params_->download_policy.disallowed_types.reset();
+
+ EXPECT_FALSE(request->common_params_->download_policy.IsType(
+ blink::NavigationDownloadType::kOpenerCrossOrigin));
+ EXPECT_TRUE(request->common_params_->download_policy.IsDownloadAllowed());
+
+ // Close the initiator popup to destroy the initiator RenderFrameHost. This
+ // verifies that browser-side enforcement relies on cached state recorded at
+ // NavigationRequest creation, preventing a race condition bypass where a
+ // compromised renderer closes itself or navigates away before policy
+ // recomputation.
+ popup->Close();
+
+ // Run browser recomputation:
+ request->ComputeDownloadPolicy();
+
+ // Verify browser recomputation enforced kOpenerCrossOrigin even after
+ // initiator RFH destruction:
+ EXPECT_TRUE(request->common_params_->download_policy.IsType(
+ blink::NavigationDownloadType::kOpenerCrossOrigin));
+ EXPECT_FALSE(request->common_params_->download_policy.IsDownloadAllowed());
+}
+
class NavigationRequestBackForwardBrowserTest
: public NavigationRequestBrowserTest,
public WebContentsObserver {
Original Bug Report
Site-Isolation bypass: Renderer-forgeable download policy leaks post-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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential security vulnerability exists where a compromised renderer can forge the kOpenerCrossOrigin download policy bit due to a lack of browser-side validation. When a navigation is redirected to a cross-origin download, the browser process potentially leaks the full, unsanitized post-redirect URL to the pre-navigation frame via a console message. This could allow an attacker to obtain sensitive query parameters or capability tokens from cross-origin download URLs.
Affected files:
content/browser/renderer_host/navigation_request.cc
Estimated timestamp from git blame: 2019-02-21
Root Cause Analysis
In content/browser/renderer_host/navigation_request.cc, the function NavigationRequest::RecordDownloadUseCountersPrePolicyCheck() is responsible for logging deprecation warnings when a download occurs under certain conditions.
Specifically, if the download policy is flagged as kOpenerCrossOrigin, the browser writes a warning to the console of the pre-navigation frame (rfh):
void NavigationRequest::RecordDownloadUseCountersPrePolicyCheck() {
RenderFrameHost* rfh = frame_tree_node_->current_frame_host();
...
if (download_policy().IsType(
blink::NavigationDownloadType::kOpenerCrossOrigin)) {
rfh->AddMessageToConsole(
blink::mojom::ConsoleMessageLevel::kError,
base::StringPrintf(
"Navigating a cross-origin opener to a download (%s) is "
"deprecated, see ...",
common_params_->url.spec().c_str())); // <-- Potential URL leak
The NavigationDownloadPolicy struct is passed from the renderer via the BeginNavigation Mojo IPC inside CommonNavigationParams. Currently, the browser process fails to validate or recompute the kOpenerCrossOrigin policy bit, trusting the value provided by the renderer. This is explicitly noted in a TODO comment in NavigationRequest::ComputeDownloadPolicy():
void NavigationRequest::ComputeDownloadPolicy() {
...
// TODO(arthursonzogni): Check if the following fields from the
// NavigationDownloadPolicy could be computed here from the browser process
// instead:
//
// [NoGesture]
// [OpenerCrossOrigin]
// [AdFrameNoGesture]
// [AdFrame]
// [Interstitial]
}
If a compromised renderer maliciously triggers a navigation with kOpenerCrossOrigin set to true, the browser will evaluate the condition as true upon receiving a download response. It will then serialize and send the full, unsanitized post-redirect URL (stored in common_params_->url) back to the pre-navigation frame via LocalFrame::AddMessageToConsole Mojo IPC.
Potential Attack Steps
Please note: The analyzing tools utilized to evaluate this codebase do not have the capability to execute code. The following sequence describes potential steps an attacker might follow to trigger this vulnerability:
- Renderer Compromise: The attacker exploits a vulnerability in the sandboxed renderer process (e.g., a V8 memory corruption bug) to gain arbitrary code execution inside the sandbox.
- Parameter Forgery: The compromised renderer initiates a navigation to a target cross-origin endpoint (e.g.,
https://victim.com/export) while forging theobserved_types.opener_cross_origin = truebit insideCommonNavigationParams::download_policyover theFrameHost::BeginNavigationMojo interface. - Redirection Handling: The request to
https://victim.com/exportis sent with the victim’s session cookies and redirects to a temporary, capability-locked resource containing query parameters (e.g.,https://cdn.victim.com/file.zip?token=SECRET_TOKEN). - Download Response: The target endpoint responds with a
Content-Disposition: attachmentheader, which classifies the response as a download. - Console Leak: The browser calls
RecordDownloadUseCountersPrePolicyCheck(). Due to the forged policy bit, it prints the full URL containingtoken=SECRET_TOKENto the pre-navigation console via Mojo IPC, allowing the compromised renderer to intercept and read the token.
Impact
This is a potential Site-Isolation bypass. A compromised renderer can maliciously exfiltrate sensitive query parameters, signed URLs, or session-bound capability tokens from cross-origin download URLs, leading to unauthorized access to user data.
Suggested Fix
- Sanitize Console URLs: In
RecordDownloadUseCountersPrePolicyCheck(), strip path and query components of the printed URL using.DeprecatedGetOriginAsURL().spec()or a similar sanitization helper before printing it to the console. This is consistent with other console warnings in the same file. - Browser-side Recomputation: Fully implement the
TODOinNavigationRequest::ComputeDownloadPolicy()by computing thekOpenerCrossOriginstate securely in the browser process rather than relying on untrusted parameters from the renderer.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.