Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Downloads
DescriptionMissing authorization in Downloads
ComponentDownloads
Sub-componentDownloads API
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker514556469
Fix commitf3825c246dc6 (chromium/src) +70/-0
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-19 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

`chrome.downloads` extension API
the API through which an extension enumerates downloads (search()), receives onCreated/onChanged/onErased events, and acts on individual items by numeric id (pause/resume/cancel/removeFile/acceptDanger/show/open/getFileIcon, and the onDeterminingFilename reply).
`ShouldExport()`
the predicate that decides whether a DownloadItem is visible to extensions; it filters out temporary downloads and items whose source is DownloadSource::INTERNAL_API.
`GetDownload()`
the internal helper that resolves a numeric download id to a DownloadItem* (consulting the profile manager and, in incognito, the incognito manager).
Sequential download ids
download ids are assigned sequentially per profile, so an extension can guess or enumerate ids for items it was never shown.

Root Cause Analysis

ShouldExport() was consistently applied on the enumeration/event surface — search() results and the onCreated/onChanged/onErased events omitted temporary and INTERNAL_API downloads — but the action functions resolved their target through GetDownload(id), which performed no such filtering. Because download ids are sequential per profile, an extension could guess the id of a hidden item and invoke id-based actions on it even though it could never see the item via search() or events, violating the invariant that an extension may only act on downloads it is authorized to observe.

The fix adds if (download_item && !ShouldExport(*download_item)) return nullptr; inside GetDownload(), after the normal and incognito lookups, so a non-exportable item is reported as if the id were unknown. Every id-based action then behaves as for a nonexistent id (errors::kInvalidId for pause/resume/removeFile, and a no-op for cancel, which never errors on unknown ids), as the added DownloadExtensionTest_InternalApiDownloadActions test confirms. The change centralizes the authorization filter at the single resolution point rather than in each action.

Key insight
The core mistake was enforcing the extension-visibility filter (ShouldExport) only on enumeration and events while the shared id-to-item resolver GetDownload() returned unfiltered items; the fix moves the filter into GetDownload() so hidden items are uniformly treated as unknown ids across all actions.

Attack Path

  1. Hidden item exists A temporary or DownloadSource::INTERNAL_API download is created (e.g. an internal browser-initiated download) that ShouldExport() excludes from search() and events.
  2. Guess the sequential id A malicious/over-reaching extension enumerates or predicts the item’s numeric id, which is assigned sequentially per profile even for hidden items.
  3. Invoke an id-based action The extension calls an action function (downloads.pause/resume/cancel/removeFile/acceptDanger/show/open/getFileIcon), which pre-patch resolved the item via unfiltered GetDownload() and operated on it.
  4. Act on an unauthorized item The extension pauses, cancels, removes the file for, or otherwise manipulates (and via getFileIcon/open/show learns about) a download it was never permitted to see, breaching the visibility boundary.

Impact Assessment

An extension with the downloads permission could perform id-based actions on — and infer the existence of — downloads that are deliberately hidden from it (temporary and INTERNAL_API items), a missing-authorization flaw affecting extension access within the browser process. The precondition is an installed extension holding the downloads API permission plus a hidden download whose sequential id it can guess. The advisory classifies this as an AuthorizationError (missing authorization in Downloads) and reports no in-the-wild exploitation; any broader consequence is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/api/downloads/downloads_api.cc
modified

Files Changed

  • chrome/browser/extensions/api/downloads/downloads_api.cc
  • chrome/browser/extensions/api/downloads/downloads_api_browsertest.cc

Audit Directions

  • Filter parity between enumeration and action
    Look for APIs that apply a visibility/authorization predicate on list/search/event surfaces but resolve individual objects (by id, handle, or name) through a separate helper that skips the predicate.
  • Guessable sequential identifiers
    Audit any API exposing sequential/predictable ids to a lower-privileged principal, and confirm object resolution re-checks authorization rather than assuming the caller could only know ids it was shown.
  • Centralize the check at the resolver
    Prefer enforcing the filter inside the single lookup function (like GetDownload()) rather than in each caller, and verify no action path bypasses that resolver.
From f3825c246dc622ce9d08d0d683221766c8126507 Mon Sep 17 00:00:00 2001
From: Yaw Frempong <[email protected]>
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 <[email protected]>
Commit-Queue: Yaw Frempong <[email protected]>
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[] = {
Loading diff…

Regression Test / PoC

shipped with the fix
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[] = {
Loading diff…

Original Bug Report

reported by [email protected]

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::Run via PromptOrWait)
  • 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.

  1. A malicious extension with the downloads permission is installed.
  2. The extension calls chrome.downloads.search({}) to discover the approximate current highest download ID.
  3. Denial of Service (Blocking Extension Installs): The extension starts a tight background loop calling chrome.downloads.cancel(guess_id) on future IDs. Because cancel silently ignores invalid or inactive IDs, this loop runs without throwing errors.
  4. The user attempts to install a new security extension from the Chrome Web Store. The browser initiates a temporary .crx download and assigns it the next sequential ID.
  5. The extension’s brute-force loop hits the new ID. DownloadsCancelFunction::Run retrieves the internal DownloadItem and, lacking a ShouldExport check, cancels it. The installation fails silently from the browser UI’s perspective.
  6. Arbitrary File Deletion: Alternatively, the extension guesses IDs of completed internal downloads and calls chrome.downloads.removeFile(guess_id). DownloadsRemoveFileFunction::Run bypasses 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.

View on issue tracker