Chrome · Extensions
CVE-2026-87479
Logic Error in Extensions
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fcontent/browser/download/download_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/bad_message.hcontent/browser/download/download_browsertest.cccontent/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/security_exploit_browsertest.cctools/metrics/histograms/metadata/stability/enums.xml
Patch
From f66e7ae1e551fe9220724dc26794e31a841ee631 Mon Sep 17 00:00:00 2001 From: Zainab Rizvi <[email protected]> Date: Mon, 10 Aug 2026 17:39:31 -0700 Subject: [PATCH] Validate referrer and user gesture in DownloadURL IPC Verify in the browser process that DownloadURLParams referrer URLs are hosted by the renderer process and that user gesture claims match the frame's actual activation state. Defense in depth: IsOffstoreInstallAllowed also requires the authoritative download URL to match the allowlist, so a spoofed referrer alone does not bypass the policy. TAG=agy CONV=8c8e3606-e3f0-4996-91a9-49faa1986cb5 Bug: 511772271 Change-Id: I631b9f68ba560c9492459bcb093e7a0b02b795f4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8205905 Reviewed-by: Bo Liu <[email protected]> Reviewed-by: Caitlin Fischer <[email protected]> Commit-Queue: Zainab Rizvi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1676852} --- diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h index 7830fbb..99953da0 100644 --- a/content/browser/bad_message.h +++ b/content/browser/bad_message.h @@ -399,6 +399,7 @@ RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL = 371, RFH_BLOB_URL_STORE_ASSOCIATED_PDF_PROCESS_BLOCKED = 372, RFH_BLOB_URL_STORE_RECEIVER_PDF_PROCESS_BLOCKED = 373, + RFH_DOWNLOAD_URL_INVALID_REFERRER = 374, // 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/download/download_browsertest.cc b/content/browser/download/download_browsertest.cc index ae876f8..065ae24 100644 --- a/content/browser/download/download_browsertest.cc +++ b/content/browser/download/download_browsertest.cc @@ -5542,6 +5542,71 @@ DownloadManagerForShell(shell())->Shutdown(); } +// Ensure that a real <a download> click preserves the user gesture claim since +// it legitimately has transient user activation. +IN_PROC_BROWSER_TEST_F(DownloadContentTest, DownloadURLWithGenuineClick) { + GURL download_url = + embedded_test_server()->GetURL("/download/download-test.lib"); + + EXPECT_TRUE( + NavigateToURL(shell(), embedded_test_server()->GetURL("/empty.html"))); + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + + EXPECT_TRUE(ExecJs(web_contents, + "let a = document.createElement('a');" + "a.id = 'downloadlink';" + "a.download = 'download-test.lib';" + "a.href = '" + + download_url.spec() + + "';" + "a.innerText = 'click me';" + "document.body.appendChild(a);")); + + std::unique_ptr<DownloadTestObserver> observer( + CreateInProgressWaiter(shell(), 1)); + EXPECT_TRUE(ExecJs(web_contents, "document.getElementById('downloadlink').click()")); + + observer->WaitForFinished(); + + std::vector<raw_ptr<download::DownloadItem, VectorExperimental>> downloads; + DownloadManagerForShell(shell())->GetAllDownloads(&downloads); + ASSERT_EQ(1u, downloads.size()); + EXPECT_TRUE(downloads[0]->HasUserGesture()); +} + +// Ensure that calling DownloadURL from a frame without transient user +// activation does not preserve a spoofed has_user_gesture claim. +IN_PROC_BROWSER_TEST_F(DownloadContentTest, DownloadURLWithoutUserActivation) { + GURL download_url = + embedded_test_server()->GetURL("/download/download-test.lib"); + + EXPECT_TRUE( + NavigateToURL(shell(), embedded_test_server()->GetURL("/empty.html"))); + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + RenderFrameHostImpl* main_frame = + web_contents->GetPrimaryFrameTree().root()->current_frame_host(); + + EXPECT_FALSE(main_frame->HasTransientUserActivation()); + + std::unique_ptr<DownloadTestObserver> observer( + CreateInProgressWaiter(shell(), 1)); + + auto params = blink::mojom::DownloadURLParams::New(); + params->url = download_url; + params->initiator_origin = main_frame->GetLastCommittedOrigin(); + params->has_user_gesture = true; + main_frame->DownloadURL(std::move(params)); + + observer->WaitForFinished(); + + std::vector<raw_ptr<download::DownloadItem, VectorExperimental>> downloads; + DownloadManagerForShell(shell())->GetAllDownloads(&downloads); + ASSERT_EQ(1u, downloads.size()); + EXPECT_FALSE(downloads[0]->HasUserGesture()); +} + using DownloadRangeTestParams = std::tuple<int64_t /*starting byte in range request*/, int64_t /*ending byte in range request*/, diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc index ee707728..66e72aff 100644 --- a/content/browser/renderer_host/ipc_utils.cc +++ b/content/browser/renderer_host/ipc_utils.cc @@ -200,6 +200,17 @@ !VerifyInitiatorOrigin(process_id, *params.initiator_origin)) return false; + // Verify |params.referrer|. + if (params.referrer && !params.referrer->url.is_empty()) { + auto* policy = ChildProcessSecurityPolicyImpl::GetInstance(); + if (!policy->HostsOrigin(process_id.GetUnsafeValue(), + url::Origin::Create(params.referrer->url))) { + bad_message::ReceivedBadMessage( + process_id, bad_message::RFH_DOWNLOAD_URL_INVALID_REFERRER); + return false; + } + } + // If |params.url| is not set, this must be a large data URL being passed // through |params.data_url_blob|. if (!params.url.is_valid() && !params.data_url_blob.is_valid()) diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index 253d0b8..f383f99 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -8000,7 +8000,9 @@ std::unique_ptr<download::DownloadUrlParameters> parameters = CreateDownloadUrlParameters(blink_parameters->url, traffic_annotation); parameters->set_content_initiated(!blink_parameters->is_context_menu_save); - parameters->set_has_user_gesture(blink_parameters->has_user_gesture); + // Ensure that user gesture claims match the current activation state. + parameters->set_has_user_gesture(blink_parameters->has_user_gesture && + HasTransientUserActivation()); parameters->set_suggested_name( blink_parameters->suggested_name.value_or(std::u16string())); parameters->set_prompt(blink_parameters->is_context_menu_save); diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc index ade25ac..19bb216 100644 --- a/content/browser/security_exploit_browsertest.cc +++ b/content/browser/security_exploit_browsertest.cc @@ -5701,4 +5701,33 @@ EXPECT_FALSE(subframe->IsRenderFrameLive()); } +// Ensure that the renderer is terminated if it sends a DownloadURL IPC with a +// referrer URL that the renderer's process is not allowed to host. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, DownloadURLInvalidReferrer) { + // Explicitly isolating a.com helps ensure that this test is applicable on + // platforms without site-per-process. + IsolateOrigin("a.com"); + + GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + RenderFrameHostImpl* main_frame = + web_contents->GetPrimaryFrameTree().root()->current_frame_host(); + + // Simulate that the renderer sends a DownloadURL IPC claiming that the + // download was referred by a page on a different site. + auto params = blink::mojom::DownloadURLParams::New(); + params->url = embedded_test_server()->GetURL("a.com", "/title2.html"); + params->initiator_origin = main_frame->GetLastCommittedOrigin(); + params->referrer = blink::mojom::Referrer::New(); + params->referrer->url = + embedded_test_server()->GetURL("b.com", "/title1.html"); + RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame->GetProcess()); + main_frame->DownloadURL(std::move(params)); + EXPECT_EQ(bad_message::RFH_DOWNLOAD_URL_INVALID_REFERRER, kill_waiter.Wait()); + EXPECT_FALSE(main_frame->IsRenderFrameLive()); +} + } // namespace content diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml index 5015d75d..396a174 100644 --- a/tools/metrics/histograms/metadata/stability/enums.xml +++ b/tools/metrics/histograms/metadata/stability/enums.xml @@ -550,6 +550,7 @@ <int value="371" label="RFHI_INVALID_CLIENT_SIDE_REDIRECT_URL"/> <int value="372" label="RFH_BLOB_URL_STORE_ASSOCIATED_PDF_PROCESS_BLOCKED"/> <int value="373" label="RFH_BLOB_URL_STORE_RECEIVER_PDF_PROCESS_BLOCKED"/> + <int value="374" label="RFH_DOWNLOAD_URL_INVALID_REFERRER"/> </enum> <enum name="BadMessageReasonExtensions">
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/content/browser/download/download_browsertest.cc b/content/browser/download/download_browsertest.cc
index ae876f8..065ae24 100644
--- a/content/browser/download/download_browsertest.cc
+++ b/content/browser/download/download_browsertest.cc
@@ -5542,6 +5542,71 @@
DownloadManagerForShell(shell())->Shutdown();
}
+// Ensure that a real <a download> click preserves the user gesture claim since
+// it legitimately has transient user activation.
+IN_PROC_BROWSER_TEST_F(DownloadContentTest, DownloadURLWithGenuineClick) {
+ GURL download_url =
+ embedded_test_server()->GetURL("/download/download-test.lib");
+
+ EXPECT_TRUE(
+ NavigateToURL(shell(), embedded_test_server()->GetURL("/empty.html")));
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ EXPECT_TRUE(ExecJs(web_contents,
+ "let a = document.createElement('a');"
+ "a.id = 'downloadlink';"
+ "a.download = 'download-test.lib';"
+ "a.href = '" +
+ download_url.spec() +
+ "';"
+ "a.innerText = 'click me';"
+ "document.body.appendChild(a);"));
+
+ std::unique_ptr<DownloadTestObserver> observer(
+ CreateInProgressWaiter(shell(), 1));
+ EXPECT_TRUE(ExecJs(web_contents, "document.getElementById('downloadlink').click()"));
+
+ observer->WaitForFinished();
+
+ std::vector<raw_ptr<download::DownloadItem, VectorExperimental>> downloads;
+ DownloadManagerForShell(shell())->GetAllDownloads(&downloads);
+ ASSERT_EQ(1u, downloads.size());
+ EXPECT_TRUE(downloads[0]->HasUserGesture());
+}
+
+// Ensure that calling DownloadURL from a frame without transient user
+// activation does not preserve a spoofed has_user_gesture claim.
+IN_PROC_BROWSER_TEST_F(DownloadContentTest, DownloadURLWithoutUserActivation) {
+ GURL download_url =
+ embedded_test_server()->GetURL("/download/download-test.lib");
+
+ EXPECT_TRUE(
+ NavigateToURL(shell(), embedded_test_server()->GetURL("/empty.html")));
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ RenderFrameHostImpl* main_frame =
+ web_contents->GetPrimaryFrameTree().root()->current_frame_host();
+
+ EXPECT_FALSE(main_frame->HasTransientUserActivation());
+
+ std::unique_ptr<DownloadTestObserver> observer(
+ CreateInProgressWaiter(shell(), 1));
+
+ auto params = blink::mojom::DownloadURLParams::New();
+ params->url = download_url;
+ params->initiator_origin = main_frame->GetLastCommittedOrigin();
+ params->has_user_gesture = true;
+ main_frame->DownloadURL(std::move(params));
+
+ observer->WaitForFinished();
+
+ std::vector<raw_ptr<download::DownloadItem, VectorExperimental>> downloads;
+ DownloadManagerForShell(shell())->GetAllDownloads(&downloads);
+ ASSERT_EQ(1u, downloads.size());
+ EXPECT_FALSE(downloads[0]->HasUserGesture());
+}
+
using DownloadRangeTestParams =
std::tuple<int64_t /*starting byte in range request*/,
int64_t /*ending byte in range request*/,
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index ade25ac..19bb216 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -5701,4 +5701,33 @@
EXPECT_FALSE(subframe->IsRenderFrameLive());
}
+// Ensure that the renderer is terminated if it sends a DownloadURL IPC with a
+// referrer URL that the renderer's process is not allowed to host.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, DownloadURLInvalidReferrer) {
+ // Explicitly isolating a.com helps ensure that this test is applicable on
+ // platforms without site-per-process.
+ IsolateOrigin("a.com");
+
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ RenderFrameHostImpl* main_frame =
+ web_contents->GetPrimaryFrameTree().root()->current_frame_host();
+
+ // Simulate that the renderer sends a DownloadURL IPC claiming that the
+ // download was referred by a page on a different site.
+ auto params = blink::mojom::DownloadURLParams::New();
+ params->url = embedded_test_server()->GetURL("a.com", "/title2.html");
+ params->initiator_origin = main_frame->GetLastCommittedOrigin();
+ params->referrer = blink::mojom::Referrer::New();
+ params->referrer->url =
+ embedded_test_server()->GetURL("b.com", "/title1.html");
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame->GetProcess());
+ main_frame->DownloadURL(std::move(params));
+ EXPECT_EQ(bad_message::RFH_DOWNLOAD_URL_INVALID_REFERRER, kill_waiter.Wait());
+ EXPECT_FALSE(main_frame->IsRenderFrameLive());
+}
+
} // namespace content
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page