CVE-2026-87510
Overview
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 resultingDownloadCreateInfo. - `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:, andfile: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.
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
- Initiate a non-HTTP download
A page triggers a download whose request URL uses a local scheme such as
blob:ordata:, so the current back entry ofurl_chain_is not HTTP(S). - Deliver an unexpected redirect
A redirect event carrying an attacker-influenced HTTP(S)
redirect_info.new_urlreachesDownloadResponseHandler::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). - Chain rebinding (pre-patch)
Without the guard, the handler appends
redirect_info.new_urltourl_chain_, so theDownloadItem’s URL chain and referrer become bound to a URL other than the one actually loaded, corrupting the download’s recorded provenance. - 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
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
| Function | Change | Notes |
|---|---|---|
FakeDelegatecomponents/download/internal/common/download_response_handler_unittest.cc |
modified | |
DownloadResponseHandlerTestcomponents/download/internal/common/download_response_handler_unittest.cc |
modified | |
TEST_Fcomponents/download/internal/common/download_response_handler_unittest.cc |
modified |
Files Changed
components/download/internal/common/download_response_handler.cccomponents/download/internal/common/BUILD.gncomponents/download/internal/common/DEPScomponents/download/internal/common/download_response_handler_unittest.cc
Audit Directions
- Scheme assumptions on redirect handlingAudit 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 invariantsReview 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 fieldsLook at other places that build
DownloadCreateInfo/DownloadItemURL chains and referrers to ensure every appended URL is one the request actually loaded, not an unvalidated redirect target.
Patch
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", + ], +} +
Regression Test / PoC
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
Original Bug Report
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.ccstorage/browser/blob/blob_url_store_impl.cccomponents/download/internal/common/resource_downloader.cccomponents/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
-
Register a Renderer-Hosted Blob: A compromised renderer creates a Mojo pipe for
blink.mojom.Blob, keeping theReceiverend and implementing a malicious, self-hostedBlobinterface. It callsBlobURLStore::Registerwith a URL of its own origin (e.g.,blob:https://evil.com/UUID) and thePendingRemote<Blob>endpoint. -
Obtain a Blob URL Token: The renderer resolves the registered blob URL as a token by calling
BlobURLStore::ResolveAsBlobURLTokento receive ablink::mojom::BlobURLTokenremote mapping to the renderer’s hosted Blob. -
Initiate the Download: The renderer calls
LocalFrameHost::DownloadURLvia Mojo, supplying the blob URL, the token, and settingcross_origin_redirectstonetwork::mojom::RedirectMode::kFollow. -
Browser Plumbs the Loader Factory: The browser-process
RenderFrameHostImpl::DownloadURLbuilds aBlobURLLoaderFactorywrapping the token’s remote, passes it to theInProgressDownloadManager, and executes the load on the IO thread viaResourceDownloader::Start. -
Handover of Mojo Endpoints:
ResourceDownloader::Startcreates a browser-sideDownloadResponseHandlerto act as theURLLoaderClient. It then callsCreateLoaderAndStarton theBlobURLLoaderFactorywith aPendingReceiver<URLLoader>and thePendingRemote<URLLoaderClient>. Because the factory is backed by the renderer’s custom Blob, both endpoints are delivered straight to the renderer’sBlob::Loadimplementation. -
Fabricate a Redirection: The renderer intercepts the
URLLoaderClientremote and callsOnReceiveRedirectwith a spoofedRedirectInfocontaining a target URL (e.g.,https://victim-bank.com/secure/payload.exe) and referrer. -
Bypass Cross-Origin Restrictions: The browser’s
DownloadResponseHandler::OnReceiveRedirectprocesses the redirect. Sincecross_origin_redirectswas set tokFollowby the renderer, the cross-origin check is bypassed, and the forged URL is appended to the download’surl_chain_. -
Serve the Payload: The renderer calls
OnReceiveResponseon theURLLoaderClientremote, providing a fakeURLResponseHeadand 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-2036andcomponents/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.