Medium watch CVSS 8.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in FileAPI
DescriptionImproper input validation in FileAPI
ComponentFileAPI
Sub-componentDownload
Bug ClassInput Validation
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.
Tracker532957878
Fix commit6617d58a8cc7 (chromium/src) +185/-0
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-08-03 (public git)
Disclosed2026-09-08
Patch gap36 days — fix public in source before the advisory

Background

`DownloadResponseHandler`
the component (in components/download/internal/common/download_response_handler.cc) that consumes a network load’s response events for a download and builds the resulting DownloadCreateInfo.
`OnReceiveRedirect()`
the handler callback invoked when the loader reports an HTTP redirect; pre-patch it unconditionally appended the redirect target to the download’s URL chain.
`url_chain_`
the ordered list of URLs the download has traversed; its entries become the DownloadItem’s URL chain and drive the download’s recorded origin/referrer.
Local-scheme loaders
loaders for schemes such as blob:, data:, and file: serve bytes directly and never legitimately emit HTTP redirects.

Root Cause Analysis

Pre-patch, DownloadResponseHandler::OnReceiveRedirect() accepted any redirect report and proceeded to append redirect_info.new_url to url_chain_, regardless of what scheme the current request URL used. The implicit invariant is that a redirect only follows an HTTP(S) request, so the URL chain and referrer stay bound to the URL actually loaded; a redirect reported while the current request URL was a local scheme (blob:, data:, file:) violated that invariant by grafting an unrelated, potentially attacker-chosen HTTP target onto a download that had loaded local content.

The fix adds an early guard: if url_chain_ is empty or url_chain_.back().SchemeIsHTTPOrHTTPS() is false, it sets abort_reason_ = DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST, calls OnComplete(network::URLLoaderCompletionStatus(net::OK)), and returns before any URL is appended. This is purely an added input-validation check that runs before the existing web-safe-URL check and the chain-append logic, so the redirect is treated as an invalid request instead of being followed. The regression tests confirm the intended behavior: for blob:, data:, and file: request URLs the redirect count stays 0, the result is DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST, and the url_chain retains exactly one entry equal to the original local URL, while HTTP(S) requests still follow redirects.

Key insight
The core mistake was that OnReceiveRedirect() never checked the scheme of the current request before extending url_chain_, allowing a redirect reported on a non-HTTP(S) (local-scheme) download to rebind the URL chain and referrer to an unrelated target; the fix rejects any redirect whose current request URL is not HTTP(S) as an invalid request.

Attack Path

  1. Initiate a non-HTTP download A page triggers a download whose request URL uses a local scheme such as blob: or data:, so the current back entry of url_chain_ is not HTTP(S).
  2. Deliver an unexpected redirect A redirect event carrying an attacker-influenced HTTP(S) redirect_info.new_url reaches DownloadResponseHandler::OnReceiveRedirect() on that local-scheme download (inferred, since the diff shows only that the handler previously trusted such a report, not the exact loader path that produced it).
  3. Chain rebinding (pre-patch) Without the guard, the handler appends redirect_info.new_url to url_chain_, so the DownloadItem’s URL chain and referrer become bound to a URL other than the one actually loaded, corrupting the download’s recorded provenance.
  4. Downstream security-decision confusion Because the URL chain and referrer feed the download’s origin/referrer accounting, the spoofed chain could mislead provenance-dependent logic such as referrer reporting or safety checks (inferred; the patch only adds a validation check and does not demonstrate a concrete downstream sink or escalation).

Impact Assessment

An attacker could cause a locally-loaded download (blob:/data:/file:) to record a URL chain and referrer bound to an unrelated HTTP(S) URL rather than the URL actually loaded, corrupting the download’s provenance in the browser process. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.3. Any escalation from the spoofed URL chain to a concrete security-decision bypass or further compromise is (inferred), as the patch adds only a rejection check and demonstrates no downstream sink. Preconditions are that the victim initiates a download over a non-HTTP scheme and that a redirect is reported on that request.

Changed Functions

FunctionChangeNotes
FakeDelegate
components/download/internal/common/download_response_handler_unittest.cc
modified
DownloadResponseHandlerTest
components/download/internal/common/download_response_handler_unittest.cc
modified
TEST_F
components/download/internal/common/download_response_handler_unittest.cc
modified

Files Changed

  • components/download/internal/common/download_response_handler.cc
  • components/download/internal/common/BUILD.gn
  • components/download/internal/common/DEPS
  • components/download/internal/common/download_response_handler_unittest.cc

Audit Directions

  • Scheme assumptions on redirect handling
    Audit other redirect/response callbacks that append to a URL chain or recompute referrer/origin without first confirming the current request scheme is HTTP(S).
  • Local-scheme loader invariants
    Review code paths shared between HTTP(S) and local-scheme (blob:, data:, file:) loaders where an event that is only valid for HTTP is processed without gating on the actual scheme.
  • Provenance-tracking fields
    Look at other places that build DownloadCreateInfo / DownloadItem URL chains and referrers to ensure every appended URL is one the request actually loaded, not an unvalidated redirect target.
From 6617d58a8cc7734783373152143f70a974afc9a0 Mon Sep 17 00:00:00 2001
From: Minoru Chikamune <[email protected]>
Date: Mon, 03 Aug 2026 03:12:51 -0700
Subject: [PATCH] [download] Reject redirects for non-HTTP download request URLs

DownloadResponseHandler::OnReceiveRedirect() unconditionally appended
the redirect target to the download's URL chain. Only HTTP(S) responses
can produce HTTP redirects; loaders for local schemes such as blob: and
data: never issue them, so treat any redirect reported while the current
request URL is not HTTP(S) as an invalid request instead of following
it. This keeps the resulting DownloadItem's URL chain and referrer bound
to the URL that was actually loaded.

Add DownloadResponseHandlerTest to cover the blob:/data: cases in both
kFollow and kManual redirect modes and to anchor that HTTP(S) redirects
are still followed.

TAG=agy

Fixed: 532957878
Change-Id: I6441a09bba8cd4d0d9db0ac95bc6127f4d5d13c7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8185129
Reviewed-by: Shakti Sahu <[email protected]>
Commit-Queue: Minoru Chikamune <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1672529}
---
diff --git a/components/download/internal/common/download_response_handler.cc b/components/download/internal/common/download_response_handler.cc
index 5c6aeac..1f87d05 100644
--- a/components/download/internal/common/download_response_handler.cc
+++ b/components/download/internal/common/download_response_handler.cc
@@ -212,6 +212,16 @@
 void DownloadResponseHandler::OnReceiveRedirect(
     const net::RedirectInfo& redirect_info,
     network::mojom::URLResponseHeadPtr head) {
+  // Only responses to HTTP(S) requests can produce redirects. Loaders for
+  // local schemes such as blob: and data: never issue them, so treat a
+  // redirect while the current request URL is not HTTP(S) as invalid rather
+  // than appending the target URL to the download's URL chain.
+  if (url_chain_.empty() || !url_chain_.back().SchemeIsHTTPOrHTTPS()) {
+    abort_reason_ = DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST;
+    OnComplete(network::URLLoaderCompletionStatus(net::OK));
+    return;
+  }
+
   // Check if redirect URL is web safe.
   if (delegate_ && !delegate_->CanRequestURL(redirect_info.new_url)) {
     abort_reason_ = DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST;
diff --git a/components/download/internal/common/download_response_handler_unittest.cc b/components/download/internal/common/download_response_handler_unittest.cc
new file mode 100644
index 0000000..fc52b38
--- /dev/null
+++ b/components/download/internal/common/download_response_handler_unittest.cc
@@ -0,0 +1,167 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/download/public/common/download_response_handler.h"
+
+#include <memory>
+#include <utility>
+
+#include "components/download/public/common/download_create_info.h"
+#include "components/download/public/common/download_interrupt_reasons.h"
+#include "components/download/public/common/download_save_info.h"
+#include "components/download/public/common/download_url_parameters.h"
+#include "net/url_request/redirect_info.h"
+#include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/mojom/fetch_api.mojom-shared.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+
+namespace download {
+namespace {
+
+class FakeDelegate : public DownloadResponseHandler::Delegate {
+ public:
+  FakeDelegate() = default;
+  ~FakeDelegate() = default;
+
+  // DownloadResponseHandler::Delegate:
+  void OnResponseStarted(
+      std::unique_ptr<DownloadCreateInfo> download_create_info,
+      mojom::DownloadStreamHandlePtr stream_handle) override {
+    create_info_ = std::move(download_create_info);
+  }
+  void OnReceiveRedirect() override { ++redirect_count_; }
+  void OnResponseCompleted() override { response_completed_ = true; }
+  bool CanRequestURL(const GURL& url) override { return true; }
+  void OnUploadProgress(uint64_t bytes_uploaded) override {}
+
+  const DownloadCreateInfo* create_info() const { return create_info_.get(); }
+  int redirect_count() const { return redirect_count_; }
+  bool response_completed() const { return response_completed_; }
+
+ private:
+  std::unique_ptr<DownloadCreateInfo> create_info_;
+  int redirect_count_ = 0;
+  bool response_completed_ = false;
+};
+
+class DownloadResponseHandlerTest : public testing::Test {
+ protected:
+  std::unique_ptr<DownloadResponseHandler> CreateHandler(
+      const GURL& request_url,
+      network::mojom::RedirectMode cross_origin_redirects) {
+    resource_request_.url = request_url;
+    resource_request_.method = "GET";
+    return std::make_unique<DownloadResponseHandler>(
+        &resource_request_, &delegate_, std::make_unique<DownloadSaveInfo>(),
+        /*is_parallel_request=*/false,
+        /*is_transient=*/false,
+        /*fetch_error_body=*/false, cross_origin_redirects,
+        DownloadUrlParameters::RequestHeadersType(),
+        /*request_origin=*/std::string(), DownloadSource::UNKNOWN,
+        /*require_safety_checks=*/true, std::vector<GURL>(1, request_url),
+        /*is_background_mode=*/false);
+  }
+
+  static net::RedirectInfo MakeRedirectInfo(const GURL& new_url) {
+    net::RedirectInfo redirect_info;
+    redirect_info.status_code = 302;
+    redirect_info.new_method = "GET";
+    redirect_info.new_url = new_url;
+    return redirect_info;
+  }
+
+  network::ResourceRequest resource_request_;
+  FakeDelegate delegate_;
+};
+
+TEST_F(DownloadResponseHandlerTest, HttpRequestFollowsRedirect) {
+  const GURL kRequestUrl("https://a.example.com/file");
+  const GURL kRedirectUrl("https://b.example.com/file");
+  auto handler =
+      CreateHandler(kRequestUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(1, delegate_.redirect_count());
+  EXPECT_FALSE(delegate_.response_completed());
+  EXPECT_FALSE(delegate_.create_info());
+}
+
+// Loading a blob: URL never produces an HTTP redirect, so a redirect reported
+// while the current request URL is blob: must not be followed and must not be
+// appended to the download's URL chain.
+TEST_F(DownloadResponseHandlerTest, BlobRequestRejectsRedirect) {
+  const GURL kBlobUrl("blob:https://a.example.com/1234");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kBlobUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kBlobUrl, delegate_.create_info()->url_chain.back());
+}
+
+// Same as above with the manual redirect mode: the redirect target must not be
+// surfaced to the delegate as an interrupted cross-origin redirect either.
+TEST_F(DownloadResponseHandlerTest, BlobRequestRejectsManualRedirect) {
+  const GURL kBlobUrl("blob:https://a.example.com/1234");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kBlobUrl, network::mojom::RedirectMode::kManual);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kBlobUrl, delegate_.create_info()->url_chain.back());
+}
+
+TEST_F(DownloadResponseHandlerTest, DataRequestRejectsRedirect) {
+  const GURL kDataUrl("data:text/plain,hello");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kDataUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kDataUrl, delegate_.create_info()->url_chain.back());
+}
+
+TEST_F(DownloadResponseHandlerTest, FileRequestRejectsRedirect) {
+  const GURL kFileUrl("file:///path/to/test.file");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kFileUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kFileUrl, delegate_.create_info()->url_chain.back());
+}
+
+}  // namespace
+}  // namespace download
diff --git a/components/download/internal/common/BUILD.gn b/components/download/internal/common/BUILD.gn
index 92e1c31..3d8b3fc 100644
--- a/components/download/internal/common/BUILD.gn
+++ b/components/download/internal/common/BUILD.gn
@@ -174,6 +174,7 @@
     "download_item_impl_unittest.cc",
     "download_job_factory_unittest.cc",
     "download_path_reservation_tracker_unittest.cc",
+    "download_response_handler_unittest.cc",
     "download_stats_unittest.cc",
     "download_ukm_helper_unittest.cc",
     "download_utils_unittest.cc",
diff --git a/components/download/internal/common/DEPS b/components/download/internal/common/DEPS
index 5ecffaa..5517c38 100644
--- a/components/download/internal/common/DEPS
+++ b/components/download/internal/common/DEPS
@@ -36,3 +36,10 @@
   "+services/network/public/mojom",
   "+services/service_manager/public/cpp",
 ]
+
+specific_include_rules = {
+  ".*_unittest\\.cc": [
+    "+net/url_request/redirect_info.h",
+  ],
+}
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/download/internal/common/download_response_handler_unittest.cc b/components/download/internal/common/download_response_handler_unittest.cc
new file mode 100644
index 0000000..fc52b38
--- /dev/null
+++ b/components/download/internal/common/download_response_handler_unittest.cc
@@ -0,0 +1,167 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/download/public/common/download_response_handler.h"
+
+#include <memory>
+#include <utility>
+
+#include "components/download/public/common/download_create_info.h"
+#include "components/download/public/common/download_interrupt_reasons.h"
+#include "components/download/public/common/download_save_info.h"
+#include "components/download/public/common/download_url_parameters.h"
+#include "net/url_request/redirect_info.h"
+#include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/mojom/fetch_api.mojom-shared.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+
+namespace download {
+namespace {
+
+class FakeDelegate : public DownloadResponseHandler::Delegate {
+ public:
+  FakeDelegate() = default;
+  ~FakeDelegate() = default;
+
+  // DownloadResponseHandler::Delegate:
+  void OnResponseStarted(
+      std::unique_ptr<DownloadCreateInfo> download_create_info,
+      mojom::DownloadStreamHandlePtr stream_handle) override {
+    create_info_ = std::move(download_create_info);
+  }
+  void OnReceiveRedirect() override { ++redirect_count_; }
+  void OnResponseCompleted() override { response_completed_ = true; }
+  bool CanRequestURL(const GURL& url) override { return true; }
+  void OnUploadProgress(uint64_t bytes_uploaded) override {}
+
+  const DownloadCreateInfo* create_info() const { return create_info_.get(); }
+  int redirect_count() const { return redirect_count_; }
+  bool response_completed() const { return response_completed_; }
+
+ private:
+  std::unique_ptr<DownloadCreateInfo> create_info_;
+  int redirect_count_ = 0;
+  bool response_completed_ = false;
+};
+
+class DownloadResponseHandlerTest : public testing::Test {
+ protected:
+  std::unique_ptr<DownloadResponseHandler> CreateHandler(
+      const GURL& request_url,
+      network::mojom::RedirectMode cross_origin_redirects) {
+    resource_request_.url = request_url;
+    resource_request_.method = "GET";
+    return std::make_unique<DownloadResponseHandler>(
+        &resource_request_, &delegate_, std::make_unique<DownloadSaveInfo>(),
+        /*is_parallel_request=*/false,
+        /*is_transient=*/false,
+        /*fetch_error_body=*/false, cross_origin_redirects,
+        DownloadUrlParameters::RequestHeadersType(),
+        /*request_origin=*/std::string(), DownloadSource::UNKNOWN,
+        /*require_safety_checks=*/true, std::vector<GURL>(1, request_url),
+        /*is_background_mode=*/false);
+  }
+
+  static net::RedirectInfo MakeRedirectInfo(const GURL& new_url) {
+    net::RedirectInfo redirect_info;
+    redirect_info.status_code = 302;
+    redirect_info.new_method = "GET";
+    redirect_info.new_url = new_url;
+    return redirect_info;
+  }
+
+  network::ResourceRequest resource_request_;
+  FakeDelegate delegate_;
+};
+
+TEST_F(DownloadResponseHandlerTest, HttpRequestFollowsRedirect) {
+  const GURL kRequestUrl("https://a.example.com/file");
+  const GURL kRedirectUrl("https://b.example.com/file");
+  auto handler =
+      CreateHandler(kRequestUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(1, delegate_.redirect_count());
+  EXPECT_FALSE(delegate_.response_completed());
+  EXPECT_FALSE(delegate_.create_info());
+}
+
+// Loading a blob: URL never produces an HTTP redirect, so a redirect reported
+// while the current request URL is blob: must not be followed and must not be
+// appended to the download's URL chain.
+TEST_F(DownloadResponseHandlerTest, BlobRequestRejectsRedirect) {
+  const GURL kBlobUrl("blob:https://a.example.com/1234");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kBlobUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kBlobUrl, delegate_.create_info()->url_chain.back());
+}
+
+// Same as above with the manual redirect mode: the redirect target must not be
+// surfaced to the delegate as an interrupted cross-origin redirect either.
+TEST_F(DownloadResponseHandlerTest, BlobRequestRejectsManualRedirect) {
+  const GURL kBlobUrl("blob:https://a.example.com/1234");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kBlobUrl, network::mojom::RedirectMode::kManual);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kBlobUrl, delegate_.create_info()->url_chain.back());
+}
+
+TEST_F(DownloadResponseHandlerTest, DataRequestRejectsRedirect) {
+  const GURL kDataUrl("data:text/plain,hello");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kDataUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kDataUrl, delegate_.create_info()->url_chain.back());
+}
+
+TEST_F(DownloadResponseHandlerTest, FileRequestRejectsRedirect) {
+  const GURL kFileUrl("file:///path/to/test.file");
+  const GURL kRedirectUrl("https://b.example.com/payload");
+  auto handler = CreateHandler(kFileUrl, network::mojom::RedirectMode::kFollow);
+
+  handler->OnReceiveRedirect(MakeRedirectInfo(kRedirectUrl),
+                             network::mojom::URLResponseHead::New());
+
+  EXPECT_EQ(0, delegate_.redirect_count());
+  EXPECT_TRUE(delegate_.response_completed());
+  ASSERT_TRUE(delegate_.create_info());
+  EXPECT_EQ(DOWNLOAD_INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+            delegate_.create_info()->result);
+  ASSERT_EQ(1u, delegate_.create_info()->url_chain.size());
+  EXPECT_EQ(kFileUrl, delegate_.create_info()->url_chain.back());
+}
+
+}  // namespace
+}  // namespace download
Loading diff…

Original Bug Report

reported by [email protected]

Forge of download source origin via renderer-hosted Blob and URLLoader redirection

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 compromised renderer can register a self-hosted blink::mojom::Blob and initiate a download of its blob URL. Because the browser forwards the URLLoaderClient remote directly to the renderer-hosted Blob, the renderer can fabricate redirect and response calls to forge the download’s canonical source URL and origin. This allows a compromised renderer to potentially spoof security indicators including Mark-of-the-Web, Safe Browsing checks, the download UI, and enterprise policies.

Affected files:

  • storage/browser/blob/blob_url_loader_factory.cc
  • storage/browser/blob/blob_url_store_impl.cc
  • components/download/internal/common/resource_downloader.cc
  • components/download/internal/common/download_response_handler.cc

Estimated timestamp from git blame: 2018-02-01

Summary / Potential Impact

There is a potential vulnerability in the download subsystem of Chromium where a compromised renderer can spoof the canonical source URL and origin of a downloaded file. By hosting its own implementation of blink::mojom::Blob and initiating a download, the renderer receives the browser’s URLLoaderClient Mojo remote (implemented by DownloadResponseHandler). This allows the compromised renderer to simulate a cross-origin redirect to any arbitrary HTTPS URL (e.g., https://victim-bank.com/secure/payload.exe).

As a result, the browser completes the download, writing the renderer-supplied bytes to disk while treating the file’s origin as the spoofed URL. This allows the renderer to potentially:

  • Spoof the source metadata on disk (Windows Zone.Identifier / Mark-of-the-Web, macOS quarantine data URL).
  • Bypass Safe Browsing download protection whitelists and reputation checks.
  • Spoof the origin displayed in the Chrome download bubble or shelf.
  • Trigger enterprise auto-open policies (e.g., AutoOpenAllowedForURLs) if the spoofed URL matches an allowlist.

Note: These steps are based on static code analysis and are potential/suggested steps; our tooling does not currently have the ability to run proof-of-concept exploit code.

Potential Step-by-Step Scenario

  1. Register a Renderer-Hosted Blob: A compromised renderer creates a Mojo pipe for blink.mojom.Blob, keeping the Receiver end and implementing a malicious, self-hosted Blob interface. It calls BlobURLStore::Register with a URL of its own origin (e.g., blob:https://evil.com/UUID) and the PendingRemote<Blob> endpoint.

  2. Obtain a Blob URL Token: The renderer resolves the registered blob URL as a token by calling BlobURLStore::ResolveAsBlobURLToken to receive a blink::mojom::BlobURLToken remote mapping to the renderer’s hosted Blob.

  3. Initiate the Download: The renderer calls LocalFrameHost::DownloadURL via Mojo, supplying the blob URL, the token, and setting cross_origin_redirects to network::mojom::RedirectMode::kFollow.

  4. Browser Plumbs the Loader Factory: The browser-process RenderFrameHostImpl::DownloadURL builds a BlobURLLoaderFactory wrapping the token’s remote, passes it to the InProgressDownloadManager, and executes the load on the IO thread via ResourceDownloader::Start.

  5. Handover of Mojo Endpoints: ResourceDownloader::Start creates a browser-side DownloadResponseHandler to act as the URLLoaderClient. It then calls CreateLoaderAndStart on the BlobURLLoaderFactory with a PendingReceiver<URLLoader> and the PendingRemote<URLLoaderClient>. Because the factory is backed by the renderer’s custom Blob, both endpoints are delivered straight to the renderer’s Blob::Load implementation.

  6. Fabricate a Redirection: The renderer intercepts the URLLoaderClient remote and calls OnReceiveRedirect with a spoofed RedirectInfo containing a target URL (e.g., https://victim-bank.com/secure/payload.exe) and referrer.

  7. Bypass Cross-Origin Restrictions: The browser’s DownloadResponseHandler::OnReceiveRedirect processes the redirect. Since cross_origin_redirects was set to kFollow by the renderer, the cross-origin check is bypassed, and the forged URL is appended to the download’s url_chain_.

  8. Serve the Payload: The renderer calls OnReceiveResponse on the URLLoaderClient remote, providing a fake URLResponseHead and its malicious payload via a Mojo data pipe. The download completes, and the spoofed URL (victim-bank.com) is recorded as the primary canonical source of the download.

Code References

  • Endpoint Hand-off: storage/browser/blob/blob_url_loader_factory.cc:80-81
  • Redirect Hand-off & URL Chain Manipulation: components/download/internal/common/download_response_handler.cc:212-257
  • Downstream Sinks (MotW & Quarantine): components/download/internal/common/download_item_impl.cc:2027-2036 and components/download/internal/common/base_file.cc:619-644

Suggested Fix

To prevent this vulnerability, the browser process should ensure that the blink::mojom::Blob remote used to fetch download resources is always browser-hosted.

Specifically, VerifyDownloadUrlParams or the DownloadURL entry point should validate that any blob_url_token supplied by the renderer resolves to a blob backed by storage::BlobImpl (or similar browser-controlled storage), rather than allowing arbitrary renderer-hosted implementations to handle the Load request.

Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85


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