CVE-2026-87441
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/downloads/downloads_api.cc |
modified |
Files Changed
chrome/browser/extensions/api/downloads/downloads_api.ccchrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
Patch
From f3825c246dc622ce9d08d0d683221766c8126507 Mon Sep 17 00:00:00 2001
From: Yaw Frempong <yawfrempong@google.com>
Date: Thu, 30 Jul 2026 14:00:12 -0700
Subject: [PATCH] [Downloads API] hide non-exported items from id-based actions
ShouldExport() filters temporary and INTERNAL_API downloads from
chrome.downloads.search() and from onCreated/onChanged/onErased events,
but the action functions that take a downloadId looked the item up via
GetDownload() which did not apply the same filter. Because download ids
are sequential per profile, an extension could act on items it could not
otherwise see.
GetDownload() now returns nullptr for items that fail ShouldExport(), so
pause/resume/cancel/removeFile/acceptDanger/ show/open/getFileIcon and
the onDeterminingFilename reply all treat such ids as unknown.
Reviewed in https://crrev.com/i/9603219.
Bug: 514556469
Change-Id: Ife178f0c2e7236ddd540ad5fd42bda352c5f1ed4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162671
Reviewed-by: Min Qin <qinmin@chromium.org>
Commit-Queue: Yaw Frempong <yawfrempong@google.com>
Cr-Commit-Position: refs/heads/main@{#1671397}
---
diff --git a/chrome/browser/extensions/api/downloads/downloads_api.cc b/chrome/browser/extensions/api/downloads/downloads_api.cc
index 1759b9d..dabc7a98 100644
--- a/chrome/browser/extensions/api/downloads/downloads_api.cc
+++ b/chrome/browser/extensions/api/downloads/downloads_api.cc
@@ -587,6 +587,9 @@
if (!download_item && incognito_manager) {
download_item = incognito_manager->GetDownload(id);
}
+ if (download_item && !ShouldExport(*download_item)) {
+ return nullptr;
+ }
return download_item;
}
diff --git a/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc b/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
index 8ff4524c..2fd9cab 100644
--- a/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
+++ b/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
@@ -57,6 +57,7 @@
#include "chrome/test/base/in_process_browser_test.h"
#include "components/download/public/common/download_features.h"
#include "components/download/public/common/download_item.h"
+#include "components/download/public/common/download_url_parameters.h"
#include "components/history/core/browser/download_row.h"
#include "components/history/core/browser/history_service.h"
#include "components/prefs/pref_service.h"
@@ -88,6 +89,7 @@
#include "net/test/embedded_test_server/controllable_http_response.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/http_response.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "services/network/public/cpp/features.h"
#include "storage/browser/file_system/file_system_context.h"
#include "storage/browser/file_system/file_system_operation_runner.h"
@@ -685,6 +687,37 @@
return CreateSlowTestDownload(second_download_.get(), kSecondDownloadUrl);
}
+ DownloadItem* CreateInternalApiSlowTestDownload() {
+ if (!embedded_test_server()->Started()) {
+ StartEmbeddedTestServer();
+ }
+ std::unique_ptr<content::DownloadTestObserver> observer(
+ CreateInProgressDownloadObserver(1));
+ DownloadManager* manager = GetCurrentManager();
+
+ auto params = std::make_unique<download::DownloadUrlParameters>(
+ embedded_test_server()->GetURL(kFirstDownloadUrl),
+ TRAFFIC_ANNOTATION_FOR_TESTS);
+ params->set_download_source(download::DownloadSource::INTERNAL_API);
+ manager->DownloadUrl(std::move(params));
+
+ first_download_->WaitForRequest();
+ first_download_->Send(
+ "HTTP/1.1 200 OK\r\n"
+ "Content-type: application/octet-stream\r\n"
+ "Cache-Control: max-age=0\r\n"
+ "\r\n");
+ first_download_->Send(std::string(kDownloadSize, '*'));
+
+ observer->WaitForFinished();
+ EXPECT_EQ(1u, observer->NumDownloadsSeenInState(DownloadItem::IN_PROGRESS));
+
+ DownloadManager::DownloadVector items;
+ manager->GetAllDownloads(&items);
+ EXPECT_TRUE(!items.empty());
+ return items.empty() ? nullptr : items.back();
+ }
+
DownloadItem* CreateSlowTestDownload(
net::test_server::ControllableHttpResponse* response,
const std::string& path) {
@@ -1225,6 +1258,40 @@
EXPECT_EQ(id, result_list[0].GetInt());
}
+// Action functions that take a download id should treat downloads that are not
+// surfaced via search() or events as if the id were unknown.
+IN_PROC_BROWSER_TEST_F(DownloadExtensionTest,
+ DownloadExtensionTest_InternalApiDownloadActions) {
+ ScopedCancellingItem item(CreateInternalApiSlowTestDownload());
+ ASSERT_TRUE(item.get());
+ ASSERT_EQ(download::DownloadSource::INTERNAL_API,
+ item.get()->GetDownloadSource());
+ ASSERT_EQ(DownloadItem::IN_PROGRESS, item.get()->GetState());
+ const std::string args = DownloadItemIdAsArgList(item.get());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsPauseFunction>(), args)
+ .c_str());
+ EXPECT_FALSE(item.get()->IsPaused());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsResumeFunction>(), args)
+ .c_str());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsRemoveFileFunction>(), args)
+ .c_str());
+
+ // cancel() never fails for unknown ids so just verify it does not affect the
+ // item.
+ EXPECT_TRUE(
+ RunFunction(base::MakeRefCounted<DownloadsCancelFunction>(), args));
+ EXPECT_EQ(DownloadItem::IN_PROGRESS, item.get()->GetState());
+}
+
IN_PROC_BROWSER_TEST_F(DownloadExtensionTest,
DownloadExtensionTest_Open_Remove_Open) {
static const HistoryDownloadInfo kHistoryInfo[] = {
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc b/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
index 8ff4524c..2fd9cab 100644
--- a/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
+++ b/chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc
@@ -57,6 +57,7 @@
#include "chrome/test/base/in_process_browser_test.h"
#include "components/download/public/common/download_features.h"
#include "components/download/public/common/download_item.h"
+#include "components/download/public/common/download_url_parameters.h"
#include "components/history/core/browser/download_row.h"
#include "components/history/core/browser/history_service.h"
#include "components/prefs/pref_service.h"
@@ -88,6 +89,7 @@
#include "net/test/embedded_test_server/controllable_http_response.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/http_response.h"
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "services/network/public/cpp/features.h"
#include "storage/browser/file_system/file_system_context.h"
#include "storage/browser/file_system/file_system_operation_runner.h"
@@ -685,6 +687,37 @@
return CreateSlowTestDownload(second_download_.get(), kSecondDownloadUrl);
}
+ DownloadItem* CreateInternalApiSlowTestDownload() {
+ if (!embedded_test_server()->Started()) {
+ StartEmbeddedTestServer();
+ }
+ std::unique_ptr<content::DownloadTestObserver> observer(
+ CreateInProgressDownloadObserver(1));
+ DownloadManager* manager = GetCurrentManager();
+
+ auto params = std::make_unique<download::DownloadUrlParameters>(
+ embedded_test_server()->GetURL(kFirstDownloadUrl),
+ TRAFFIC_ANNOTATION_FOR_TESTS);
+ params->set_download_source(download::DownloadSource::INTERNAL_API);
+ manager->DownloadUrl(std::move(params));
+
+ first_download_->WaitForRequest();
+ first_download_->Send(
+ "HTTP/1.1 200 OK\r\n"
+ "Content-type: application/octet-stream\r\n"
+ "Cache-Control: max-age=0\r\n"
+ "\r\n");
+ first_download_->Send(std::string(kDownloadSize, '*'));
+
+ observer->WaitForFinished();
+ EXPECT_EQ(1u, observer->NumDownloadsSeenInState(DownloadItem::IN_PROGRESS));
+
+ DownloadManager::DownloadVector items;
+ manager->GetAllDownloads(&items);
+ EXPECT_TRUE(!items.empty());
+ return items.empty() ? nullptr : items.back();
+ }
+
DownloadItem* CreateSlowTestDownload(
net::test_server::ControllableHttpResponse* response,
const std::string& path) {
@@ -1225,6 +1258,40 @@
EXPECT_EQ(id, result_list[0].GetInt());
}
+// Action functions that take a download id should treat downloads that are not
+// surfaced via search() or events as if the id were unknown.
+IN_PROC_BROWSER_TEST_F(DownloadExtensionTest,
+ DownloadExtensionTest_InternalApiDownloadActions) {
+ ScopedCancellingItem item(CreateInternalApiSlowTestDownload());
+ ASSERT_TRUE(item.get());
+ ASSERT_EQ(download::DownloadSource::INTERNAL_API,
+ item.get()->GetDownloadSource());
+ ASSERT_EQ(DownloadItem::IN_PROGRESS, item.get()->GetState());
+ const std::string args = DownloadItemIdAsArgList(item.get());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsPauseFunction>(), args)
+ .c_str());
+ EXPECT_FALSE(item.get()->IsPaused());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsResumeFunction>(), args)
+ .c_str());
+
+ EXPECT_STREQ(errors::kInvalidId,
+ RunFunctionAndReturnError(
+ base::MakeRefCounted<DownloadsRemoveFileFunction>(), args)
+ .c_str());
+
+ // cancel() never fails for unknown ids so just verify it does not affect the
+ // item.
+ EXPECT_TRUE(
+ RunFunction(base::MakeRefCounted<DownloadsCancelFunction>(), args));
+ EXPECT_EQ(DownloadItem::IN_PROGRESS, item.get()->GetState());
+}
+
IN_PROC_BROWSER_TEST_F(DownloadExtensionTest,
DownloadExtensionTest_Open_Remove_Open) {
static const HistoryDownloadInfo kHistoryInfo[] = {
Original Bug Report
Missing ShouldExport checks in chrome.downloads APIs allow interaction with internal downloads
Flapjack, 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: Several functions in the chrome.downloads extension API fail to verify if a download item should be accessible to extensions via the ShouldExport utility. Because download IDs are sequentially generated, a malicious extension can guess IDs and use action APIs like downloads.cancel or downloads.removeFile to disrupt or delete internal browser downloads, such as Web Store extension installations or Optimization Guide models.
Affected files:
chrome/browser/extensions/api/downloads/downloads_api.cc
Estimated timestamp from git blame: 2020-04-17
Description
The chrome.downloads extension API uses a ShouldExport utility function (chrome/browser/extensions/api/downloads/downloads_api.cc:512) to enforce a security boundary. It filters out downloads that extensions should not be able to interact with, specifically “temporary” downloads (e.g., .crx extension installation files via WebstoreInstaller) and “internal” downloads (e.g., DownloadSource::INTERNAL_API used by the Optimization Guide or Background Fetch).
While ShouldExport is correctly used to suppress extension events (onCreated, onChanged) and filter downloads.search results, it is missing from several ‘action’ API functions that take a downloadId as an argument.
Because DownloadItem IDs are sequentially generated uint32_t integers scoped to the current BrowserContext (content/browser/download/download_manager_impl.cc:419), an attacker can easily guess upcoming or past IDs. A malicious extension can bypass the visibility restrictions by directly calling action APIs with guessed IDs.
The following functions in chrome/browser/extensions/api/downloads/downloads_api.cc retrieve a DownloadItem using GetDownload() and validate it with InvalidId(), but fail to call ShouldExport before performing operations:
downloads.pause(DownloadsPauseFunction::Run)downloads.resume(DownloadsResumeFunction::Run)downloads.cancel(DownloadsCancelFunction::Run)downloads.acceptDanger(DownloadsAcceptDangerFunction::RunviaPromptOrWait)downloads.show(DownloadsShowFunction::Run)downloads.open(DownloadsOpenFunction::Run)downloads.removeFile(DownloadsRemoveFileFunction::Run)downloads.getFileIcon(DownloadsGetFileIconFunction::Run)
Potential Exploitation Steps
Note: These are potential steps based on static analysis; our tooling agent does not execute code.
- A malicious extension with the
downloadspermission is installed. - The extension calls
chrome.downloads.search({})to discover the approximate current highest download ID. - Denial of Service (Blocking Extension Installs): The extension starts a tight background loop calling
chrome.downloads.cancel(guess_id)on future IDs. Becausecancelsilently ignores invalid or inactive IDs, this loop runs without throwing errors. - The user attempts to install a new security extension from the Chrome Web Store. The browser initiates a temporary
.crxdownload and assigns it the next sequential ID. - The extension’s brute-force loop hits the new ID.
DownloadsCancelFunction::Runretrieves the internalDownloadItemand, lacking aShouldExportcheck, cancels it. The installation fails silently from the browser UI’s perspective. - Arbitrary File Deletion: Alternatively, the extension guesses IDs of completed internal downloads and calls
chrome.downloads.removeFile(guess_id).DownloadsRemoveFileFunction::Runbypasses the export check and deletes the physical file downloaded by internal browser components (e.g., Optimization Guide models), potentially corrupting internal state.
Suggested Fix
All API action functions in chrome/browser/extensions/api/downloads/downloads_api.cc that retrieve a DownloadItem by ID should be updated to verify the item using ShouldExport(*download_item) before proceeding. This could be efficiently implemented by updating the InvalidId() helper function to optionally or mandatorily perform the ShouldExport check, or by adding the check alongside existing state validations in the Run() methods.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.