CVE-2026-87649
Overview
Background
- `DownloadRequestLimiter::TabDownloadState`
- the per-tab object that tracks a tab’s download permission state and holds the queue of pending download decisions while a prompt is displayed.
- Multiple-download prompt
- the permission UI Chrome shows when a page tries to start more than one automatic download in a tab, asking the user to allow or block further downloads for that site.
- `callbacks_`
- the per-tab list of pending download-decision callbacks that are waiting for the user’s answer to the prompt; each callback is later invoked with
allow == trueorfalse. - `NotifyCallbacks`
- the method that drains
callbacks_and runs each queued callback with the user’s allow/deny decision once the prompt is resolved.
Root Cause Analysis
Before the patch, callbacks_ was a flat std::vector<DownloadRequestLimiter::Callback> that recorded no information about which origin initiated each queued download. While the multiple-download prompt was showing for one origin, any download initiated by a different origin sharing the same tab (for example a cross-origin subframe) was appended to that same queue via PromptUserForDownload, because is_showing_prompt() short-circuits and the request simply joins the pending list. When the user answered the prompt, Accept called NotifyCallbacks(true), which released every queued callback with allow == true regardless of origin, violating the invariant that a user’s permission decision applies only to the origin the prompt was actually shown for.
The fix changes callbacks_ to a std::vector<std::pair<url::Origin, DownloadRequestLimiter::Callback>>, tags each callback with its request_origin at enqueue time, and reworks NotifyCallbacks(request_origin, allow) to split the queue into callbacks matching request_origin (which receive allow) and all others (which are unconditionally cancelled with false). This restores per-origin scoping so accepting a prompt for one site can no longer authorize another site’s queued downloads.
request_origin and makes NotifyCallbacks grant allow only to callbacks whose origin matches the prompt’s origin while cancelling the rest.Attack Path
- Establish prompting state for the attacker origin
Attacker-controlled origin B (e.g. loaded as a subframe or via navigation in a tab) triggers enough downloads that
TabDownloadStateenters the prompt state and the multiple-download prompt is displayed to the user for origin B. - Queue cross-origin downloads behind the visible prompt
While the prompt is showing, another origin A active in the same tab calls
CanDownload; becauseis_showing_prompt()is true,PromptUserForDownloadappends A’s callback to the sharedcallbacks_queue with no origin tagging in the vulnerable code. - User resolves the prompt for the shown origin
The user, seeing a prompt attributed to origin B, clicks allow, driving
TabDownloadState::Acceptand the pre-patchNotifyCallbacks(true). - All queued callbacks released as allowed
NotifyCallbacks(true)swaps out the entirecallbacks_vector and posts every callback withallow == true, so origin A’s queued downloads proceed even though the user only agreed to origin B’s prompt (and vice-versa), producing the UI misrepresentation / spoof.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/browser/download/download_request_limiter.cc |
modified | |
ifchrome/browser/download/download_request_limiter.cc |
modified | |
TEST_Fchrome/browser/download/download_request_limiter_unittest.cc |
modified | |
forchrome/browser/download/download_request_limiter_unittest.cc |
modified | |
EXPECT_TRUEchrome/browser/download/download_request_limiter_unittest.cc |
modified |
Files Changed
chrome/browser/download/download_request_limiter_unittest.ccchrome/browser/download/download_request_limiter.ccchrome/browser/download/download_request_limiter.h
Audit Directions
- Shared per-tab/per-container queues keyed by nothingAudit any structure that batches pending security decisions (downloads, permissions, dialogs) across origins in one tab or WebContents and later resolves them in bulk; confirm each entry records and is filtered by its initiating origin before a single user decision is applied.
- Prompt-origin vs. request-origin mismatchLook for permission flows where
is_showing_prompt()-style short-circuits let a later, different-origin request attach to an in-flight prompt, so the resolution applies the prompt origin’s answer to a request from another origin. - Bulk `swap`/drain-all release patternsFlag code that swaps or clears an entire callback/task vector and applies one boolean outcome to all elements; verify the outcome is per-element scoped rather than uniformly applied across mixed-origin entries.
Patch
From 313c1ce85165833b914300836bee74222342b4d3 Mon Sep 17 00:00:00 2001
From: Yaw Frempong <yawfrempong@google.com>
Date: Wed, 05 Aug 2026 15:16:57 -0700
Subject: [PATCH] [Downloads] Scope queued download callbacks to requesting origin
DownloadRequestLimiter::TabDownloadState queues pending download
decisions in a single per-tab vector while the multiple-download
permission prompt is showing. Downloads initiated by an origin other
than the one shown in the prompt would join that queue and be released
with allow=true when the user accepted the prompt.
Tag each queued callback with its requesting origin and have
NotifyCallbacks() apply the user's decision only to callbacks that match
the prompt's origin; queued callbacks for any other origin are
cancelled.
Reviewed in https://crrev.com/i/9588737
Bug: 514055890
Change-Id: I858c3cb6fc07faacee96cd4c2cd073a003535f7d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8165050
Reviewed-by: Min Qin <qinmin@chromium.org>
Commit-Queue: Yaw Frempong <yawfrempong@google.com>
Cr-Commit-Position: refs/heads/main@{#1674546}
---
diff --git a/chrome/browser/download/download_request_limiter.cc b/chrome/browser/download/download_request_limiter.cc
index eb1ace9..011a277 100644
--- a/chrome/browser/download/download_request_limiter.cc
+++ b/chrome/browser/download/download_request_limiter.cc
@@ -139,7 +139,7 @@
if (!shouldClearDownloadState(navigation_handle))
return;
- NotifyCallbacks(false);
+ NotifyCallbacks(origin_, false);
host_->Remove(this, web_contents());
}
@@ -173,7 +173,7 @@
// just initially navigated to this page. See http://crbug.com/40299431.
// However, explicitly leave the limiter in place if the navigation was
// renderer-initiated and we are in a prompt state.
- NotifyCallbacks(false);
+ NotifyCallbacks(origin_, false);
host_->Remove(this, web_contents());
return;
// WARNING: We've been deleted.
@@ -198,7 +198,7 @@
// Tab closed, no need to handle closing the dialog as it's owned by the
// WebContents.
- NotifyCallbacks(false);
+ NotifyCallbacks(origin_, false);
host_->Remove(this, web_contents());
// WARNING: We've been deleted.
}
@@ -206,7 +206,7 @@
void DownloadRequestLimiter::TabDownloadState::PromptUserForDownload(
DownloadRequestLimiter::Callback callback,
const url::Origin& request_origin) {
- callbacks_.push_back(std::move(callback));
+ callbacks_.emplace_back(request_origin, std::move(callback));
DCHECK(web_contents_);
if (is_showing_prompt())
return;
@@ -263,14 +263,14 @@
void DownloadRequestLimiter::TabDownloadState::Cancel(
const url::Origin& request_origin) {
SetContentSetting(CONTENT_SETTING_BLOCK, request_origin);
- bool throttled = NotifyCallbacks(false);
+ bool throttled = NotifyCallbacks(request_origin, false);
SetDownloadStatusAndNotify(request_origin, throttled ? PROMPT_BEFORE_DOWNLOAD
: DOWNLOADS_NOT_ALLOWED);
}
void DownloadRequestLimiter::TabDownloadState::CancelOnce(
const url::Origin& request_origin) {
- bool throttled = NotifyCallbacks(false);
+ bool throttled = NotifyCallbacks(request_origin, false);
SetDownloadStatusAndNotify(request_origin, throttled ? PROMPT_BEFORE_DOWNLOAD
: DOWNLOADS_NOT_ALLOWED);
}
@@ -278,7 +278,7 @@
void DownloadRequestLimiter::TabDownloadState::Accept(
const url::Origin& request_origin) {
SetContentSetting(CONTENT_SETTING_ALLOW, request_origin);
- bool throttled = NotifyCallbacks(true);
+ bool throttled = NotifyCallbacks(request_origin, true);
SetDownloadStatusAndNotify(
request_origin, throttled ? PROMPT_BEFORE_DOWNLOAD : ALLOW_ALL_DOWNLOADS);
}
@@ -379,34 +379,52 @@
setting);
}
-bool DownloadRequestLimiter::TabDownloadState::NotifyCallbacks(bool allow) {
- std::vector<DownloadRequestLimiter::Callback> callbacks;
+bool DownloadRequestLimiter::TabDownloadState::NotifyCallbacks(
+ const url::Origin& request_origin,
+ bool allow) {
+ std::vector<DownloadRequestLimiter::Callback> requesting_origin_callbacks;
+ std::vector<DownloadRequestLimiter::Callback> other_origin_callbacks;
+ for (auto& [origin, callback] : callbacks_) {
+ if (origin == request_origin) {
+ requesting_origin_callbacks.push_back(std::move(callback));
+ } else {
+ other_origin_callbacks.push_back(std::move(callback));
+ }
+ }
+ callbacks_.clear();
+
bool throttled = false;
// Selectively send first few notifications only if number of downloads exceed
// kMaxDownloadsAtOnce. In that case, we also retain the infobar instance and
// don't close it. If allow is false, we send all the notifications to cancel
// all remaining downloads and close the infobar.
- if (!allow || (callbacks_.size() < kMaxDownloadsAtOnce)) {
+ if (!allow || (requesting_origin_callbacks.size() < kMaxDownloadsAtOnce)) {
// Null the generated weak pointer so we don't get notified again.
factory_.InvalidateWeakPtrs();
- callbacks.swap(callbacks_);
} else {
- std::vector<DownloadRequestLimiter::Callback>::iterator start, end;
- start = callbacks_.begin();
- end = callbacks_.begin() + kMaxDownloadsAtOnce;
- callbacks.assign(std::make_move_iterator(start),
- std::make_move_iterator(end));
- callbacks_.erase(start, end);
+ auto start = requesting_origin_callbacks.begin() + kMaxDownloadsAtOnce;
+ auto end = requesting_origin_callbacks.end();
+ for (auto it = start; it != end; ++it) {
+ callbacks_.emplace_back(request_origin, std::move(*it));
+ }
+ requesting_origin_callbacks.erase(start, end);
throttled = true;
}
- for (auto& callback : callbacks) {
+ for (auto& callback : requesting_origin_callbacks) {
// When callback runs, it can cause the WebContents to be destroyed.
content::GetUIThreadTaskRunner({})->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), allow));
}
+ // Downloads queued for other origins are not covered by the user's decision
+ // for `request_origin`, so cancel them.
+ for (auto& callback : other_origin_callbacks) {
+ content::GetUIThreadTaskRunner({})->PostTask(
+ FROM_HERE, base::BindOnce(std::move(callback), false));
+ }
+
return throttled;
}
diff --git a/chrome/browser/download/download_request_limiter.h b/chrome/browser/download/download_request_limiter.h
index e5b2770..d9bf211 100644
--- a/chrome/browser/download/download_request_limiter.h
+++ b/chrome/browser/download/download_request_limiter.h
@@ -10,6 +10,7 @@
#include <map>
#include <set>
#include <string>
+#include <utility>
#include <vector>
#include "base/functional/callback.h"
@@ -176,8 +177,11 @@
const url::Origin& request_origin);
// Notifies the callbacks as to whether the download is allowed or not.
- // Returns false if it didn't notify all callbacks.
- bool NotifyCallbacks(bool allow);
+ // Only callbacks queued for `request_origin` are eligible to be allowed;
+ // callbacks queued for any other origin are always notified with false.
+ // Returns true if downloads were throttled and remaining callbacks were
+ // kept queued.
+ bool NotifyCallbacks(const url::Origin& request_origin, bool allow);
// Set the download limiter state and notify if it has changed. Callers must
// guarantee that |status| and |setting| correspond to each other.
@@ -208,10 +212,11 @@
// True if a download has been seen on the current page load.
bool download_seen_;
- // Callbacks we need to notify. This is only non-empty if we're showing a
- // dialog.
+ // Callbacks we need to notify, paired with the origin that initiated each
+ // download. This is only non-empty if we're showing a dialog.
// See description above CanDownload for details on lifetime of callbacks.
- std::vector<DownloadRequestLimiter::Callback> callbacks_;
+ std::vector<std::pair<url::Origin, DownloadRequestLimiter::Callback>>
+ callbacks_;
// Origins that have non-default download state.
using DownloadStatusMap = std::map<url::Origin, DownloadStatus>;
diff --git a/chrome/browser/download/download_request_limiter_unittest.cc b/chrome/browser/download/download_request_limiter_unittest.cc
index d92148d7..259ceddc 100644
--- a/chrome/browser/download/download_request_limiter_unittest.cc
+++ b/chrome/browser/download/download_request_limiter_unittest.cc
@@ -9,6 +9,7 @@
#include "base/command_line.h"
#include "base/functional/bind.h"
#include "base/run_loop.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/time/time.h"
#include "build/build_config.h"
@@ -138,6 +139,11 @@
SetHostContentSetting(web_contents, setting);
}
+ DownloadRequestLimiter::TabDownloadState* GetTabDownloadState(
+ WebContents* web_contents) {
+ return download_request_limiter_->GetDownloadState(web_contents);
+ }
+
protected:
const GURL kTestURL = GURL("http://foo.com/bar");
@@ -1025,6 +1031,58 @@
download_request_limiter_->GetDownloadUiStatus(web_contents()));
}
+// Test that accepting the multiple-download prompt only allows the queued
+// downloads that were initiated by the origin the prompt was shown for.
+TEST_F(DownloadRequestLimiterTest, AcceptOnlyAllowsPromptOriginDownloads) {
+ NavigateAndCommit(kTestURL);
+ LoadCompleted();
+
+ url::Origin main_origin = url::Origin::Create(kTestURL);
+ url::Origin other_origin = url::Origin::Create(GURL("http://foobar.com"));
+
+ // First download from the main origin is allowed and moves the tab to
+ // PROMPT_BEFORE_DOWNLOAD.
+ CanDownloadFor(kTestURL, web_contents(), main_origin);
+ ExpectAndResetCounts(1, 0, 0, __LINE__);
+ EXPECT_EQ(DownloadRequestLimiter::PROMPT_BEFORE_DOWNLOAD,
+ download_request_limiter_->GetDownloadStatus(web_contents()));
+
+ // A download from another origin in the same tab triggers the prompt for that
+ // origin. Leave the prompt visible so subsequent downloads queue behind it.
+ UpdateExpectations(WAIT);
+ CanDownloadFor(kTestURL, web_contents(), other_origin);
+ EXPECT_TRUE(mock_permission_prompt_factory_->RequestOriginSeen(
+ other_origin.GetURL()));
+ EXPECT_FALSE(
+ mock_permission_prompt_factory_->RequestOriginSeen(main_origin.GetURL()));
+ ExpectAndResetCounts(0, 0, 1, __LINE__);
+
+ // While the prompt is showing, queue several more downloads from the main
+ // origin. These must not be released by accepting the other origin's prompt.
+ for (int i = 0; i < 5; ++i) {
+ CanDownloadFor(kTestURL, web_contents(), main_origin);
+ }
+ ExpectAndResetCounts(0, 0, 0, __LINE__);
+
+ // Accept the prompt as the permission request would for `other_origin`.
+ DownloadRequestLimiter::TabDownloadState* state =
+ GetTabDownloadState(web_contents());
+ ASSERT_TRUE(state);
+ state->Accept(other_origin);
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ return state->GetDownloadStatus(other_origin) ==
+ DownloadRequestLimiter::ALLOW_ALL_DOWNLOADS;
+ }));
+
+ // Only the download queued for `other_origin` should proceed; downloads
+ // queued for the main origin should be cancelled.
+ ExpectAndResetCounts(1, 5, 0, __LINE__);
+ EXPECT_EQ(DownloadRequestLimiter::ALLOW_ALL_DOWNLOADS,
+ state->GetDownloadStatus(other_origin));
+ EXPECT_EQ(DownloadRequestLimiter::PROMPT_BEFORE_DOWNLOAD,
+ state->GetDownloadStatus(main_origin));
+}
+
// Test that user interaction on the current page won't reset download status
// for another origin.
TEST_F(DownloadRequestLimiterTest,
Regression Test / PoC
diff --git a/chrome/browser/download/download_request_limiter_unittest.cc b/chrome/browser/download/download_request_limiter_unittest.cc
index d92148d7..259ceddc 100644
--- a/chrome/browser/download/download_request_limiter_unittest.cc
+++ b/chrome/browser/download/download_request_limiter_unittest.cc
@@ -9,6 +9,7 @@
#include "base/command_line.h"
#include "base/functional/bind.h"
#include "base/run_loop.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/time/time.h"
#include "build/build_config.h"
@@ -138,6 +139,11 @@
SetHostContentSetting(web_contents, setting);
}
+ DownloadRequestLimiter::TabDownloadState* GetTabDownloadState(
+ WebContents* web_contents) {
+ return download_request_limiter_->GetDownloadState(web_contents);
+ }
+
protected:
const GURL kTestURL = GURL("http://foo.com/bar");
@@ -1025,6 +1031,58 @@
download_request_limiter_->GetDownloadUiStatus(web_contents()));
}
+// Test that accepting the multiple-download prompt only allows the queued
+// downloads that were initiated by the origin the prompt was shown for.
+TEST_F(DownloadRequestLimiterTest, AcceptOnlyAllowsPromptOriginDownloads) {
+ NavigateAndCommit(kTestURL);
+ LoadCompleted();
+
+ url::Origin main_origin = url::Origin::Create(kTestURL);
+ url::Origin other_origin = url::Origin::Create(GURL("http://foobar.com"));
+
+ // First download from the main origin is allowed and moves the tab to
+ // PROMPT_BEFORE_DOWNLOAD.
+ CanDownloadFor(kTestURL, web_contents(), main_origin);
+ ExpectAndResetCounts(1, 0, 0, __LINE__);
+ EXPECT_EQ(DownloadRequestLimiter::PROMPT_BEFORE_DOWNLOAD,
+ download_request_limiter_->GetDownloadStatus(web_contents()));
+
+ // A download from another origin in the same tab triggers the prompt for that
+ // origin. Leave the prompt visible so subsequent downloads queue behind it.
+ UpdateExpectations(WAIT);
+ CanDownloadFor(kTestURL, web_contents(), other_origin);
+ EXPECT_TRUE(mock_permission_prompt_factory_->RequestOriginSeen(
+ other_origin.GetURL()));
+ EXPECT_FALSE(
+ mock_permission_prompt_factory_->RequestOriginSeen(main_origin.GetURL()));
+ ExpectAndResetCounts(0, 0, 1, __LINE__);
+
+ // While the prompt is showing, queue several more downloads from the main
+ // origin. These must not be released by accepting the other origin's prompt.
+ for (int i = 0; i < 5; ++i) {
+ CanDownloadFor(kTestURL, web_contents(), main_origin);
+ }
+ ExpectAndResetCounts(0, 0, 0, __LINE__);
+
+ // Accept the prompt as the permission request would for `other_origin`.
+ DownloadRequestLimiter::TabDownloadState* state =
+ GetTabDownloadState(web_contents());
+ ASSERT_TRUE(state);
+ state->Accept(other_origin);
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ return state->GetDownloadStatus(other_origin) ==
+ DownloadRequestLimiter::ALLOW_ALL_DOWNLOADS;
+ }));
+
+ // Only the download queued for `other_origin` should proceed; downloads
+ // queued for the main origin should be cancelled.
+ ExpectAndResetCounts(1, 5, 0, __LINE__);
+ EXPECT_EQ(DownloadRequestLimiter::ALLOW_ALL_DOWNLOADS,
+ state->GetDownloadStatus(other_origin));
+ EXPECT_EQ(DownloadRequestLimiter::PROMPT_BEFORE_DOWNLOAD,
+ state->GetDownloadStatus(main_origin));
+}
+
// Test that user interaction on the current page won't reset download status
// for another origin.
TEST_F(DownloadRequestLimiterTest,
Original Bug Report
Cross-origin download permission bypass via shared TabDownloadState callback queue
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 logic vulnerability in DownloadRequestLimiter allows an attacker to bypass download protections by piggybacking malicious requests onto a permission prompt labeled with a different origin. This is caused by an origin-agnostic callback queue shared across all origins within a single tab.
Affected files:
chrome/browser/download/download_request_limiter.ccchrome/browser/download/download_request_limiter.h
Estimated timestamp from git blame: 2019-06-25
Summary
A potential logic flaw in DownloadRequestLimiter::TabDownloadState allows an attacker to bypass multi-download (carpet-bombing) protections. By carefully timing download requests, an attacker-controlled origin can cause the browser to release a queue of unauthorized downloads using a permission prompt that the user believes is attributed to a different, potentially trusted, origin.
Technical Details
The DownloadRequestLimiter::TabDownloadState class (defined in chrome/browser/download/download_request_limiter.h) manages the download throttling state for a single WebContents (tab). It maintains a shared queue of pending download decision callbacks: std::vector<Callback> callbacks_ (line 210).
When PromptUserForDownload is called in chrome/browser/download/download_request_limiter.cc:
- The callback is appended to the shared
callbacks_vector (line 209). - If a permission prompt is already being displayed (
is_showing_prompt()returns true), the function returns early without creating a new prompt (lines 211-212). - If no prompt is visible, a
DownloadPermissionRequestis initiated using therequest_originof the caller. This origin is displayed to the user in the permission bubble.
The flaw occurs during the resolution of the prompt. When a user clicks ‘Allow’, TabDownloadState::Accept is invoked, which calls NotifyCallbacks(true) (line 282). The NotifyCallbacks function (lines 383-412) drains the shared callbacks_ vector and executes all queued callbacks with allow=true.
Critically, the implementation does not verify that the origin associated with each individual queued callback matches the origin for which the user actually granted permission. Consequently, any requests queued while a prompt for a different origin is active will be silently allowed upon the user’s acceptance of that prompt.
Potential Attack Scenario
- An attacker at
https://evil.exampleinitiates a single download to consume the initialALLOW_ONE_DOWNLOADquota for the tab. - The attacker triggers a download from a different origin (e.g., via a hidden iframe to
https://trusted.example), causing the browser to display a permission prompt labeled ’trusted.example wants to download multiple files’. - While this prompt is visible,
evil.exampletriggers numerous additional download requests via script. These are silently added to the sharedcallbacks_queue because a prompt is already showing. - The user, trusting
trusted.example, clicks ‘Allow’. NotifyCallbacksexecutes the entire queue, releasing all pending downloads fromevil.examplesimultaneously.
Suggested Fix
Modify TabDownloadState to track the origin associated with each pending callback. The callbacks_ queue should be replaced with a structure (such as a map) that separates callbacks by origin. NotifyCallbacks should be updated to only execute callbacks that match the specific origin for which the user interacted with the prompt.
Note: These steps and the impact are based on code analysis and represent a potential vulnerability; a functional proof-of-concept has not been executed.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.