CVE-2026-87510
Overview
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/BUILD.gncomponents/download/internal/common/DEPScomponents/download/internal/common/download_response_handler.cccomponents/download/internal/common/download_response_handler_unittest.cc
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/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", + ], +} + 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);
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.