CVE-2026-15778
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
RedirectingBlobcontent/browser/blob_storage/blob_url_browsertest.cc |
modified |
Files Changed
content/browser/blob_storage/blob_url_browsertest.cccontent/browser/loader/navigation_url_loader_impl.cc
Patch
From d103c7ce9b9d0fd34a59c0b46647c6db6da5aaa5 Mon Sep 17 00:00:00 2001 From: Test User <[email protected]> Date: Sun, 05 Jul 2026 20:07:14 -0700 Subject: [PATCH] Reject redirects from blob: URL navigations NavigationURLLoaderImpl already assumes that requests to the blob: scheme are never redirected and skips interceptor setup on that basis. Make that assumption explicit in OnReceiveRedirect: if a redirect arrives while loading a blob: URL, fail the navigation with ERR_UNSAFE_REDIRECT instead of consulting bypass_redirect_checks from the response head, since the underlying Blob endpoint may live outside the browser process and a real blob load never produces a redirect. Add a content_browsertest that registers a blink::mojom::Blob whose Load() responds with OnReceiveRedirect and verifies that navigating to its blob: URL fails rather than following the redirect. TAG=agy CONV=51d0251b-c784-4f07-b4fe-93b5087402df Fixed: 513795122 Change-Id: I8525501812232c2aa2bef9422d282ad4c12112e0 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8028880 Commit-Queue: Minoru Chikamune <[email protected]> Reviewed-by: Rakina Zata Amni <[email protected]> Cr-Commit-Position: refs/heads/main@{#1656964} --- diff --git a/content/browser/blob_storage/blob_url_browsertest.cc b/content/browser/blob_storage/blob_url_browsertest.cc index 03a79efe..8e96df8 100644 --- a/content/browser/blob_storage/blob_url_browsertest.cc +++ b/content/browser/blob_storage/blob_url_browsertest.cc @@ -15,6 +15,7 @@ #include "content/browser/devtools/render_frame_devtools_agent_host.h" #include "content/browser/permissions/permission_controller_impl.h" #include "content/browser/renderer_host/render_frame_host_impl.h" +#include "content/browser/storage_partition_impl.h" #include "content/browser/web_contents/web_contents_impl.h" #include "content/public/browser/browser_context.h" #include "content/public/common/content_switches.h" @@ -24,17 +25,26 @@ #include "content/public/test/content_browser_test.h" #include "content/public/test/content_browser_test_content_browser_client.h" #include "content/public/test/content_browser_test_utils.h" +#include "content/public/test/navigation_handle_observer.h" #include "content/public/test/test_devtools_protocol_client.h" #include "content/public/test/test_utils.h" #include "content/shell/browser/shell.h" #include "content/test/content_browser_test_utils_internal.h" +#include "mojo/public/cpp/bindings/receiver_set.h" +#include "mojo/public/cpp/bindings/remote.h" #include "net/base/net_errors.h" #include "net/dns/mock_host_resolver.h" +#include "net/http/http_response_headers.h" #include "net/test/embedded_test_server/embedded_test_server.h" +#include "net/url_request/redirect_info.h" +#include "services/network/public/mojom/url_loader.mojom.h" +#include "services/network/public/mojom/url_response_head.mojom.h" +#include "storage/browser/blob/blob_url_registry.h" #include "storage/browser/blob/features.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" #include "third_party/blink/public/common/features.h" +#include "third_party/blink/public/mojom/blob/blob.mojom.h" #include "url/gurl.h" #include "url/origin.h" @@ -229,6 +239,108 @@ EXPECT_FALSE(base::MatchPattern(window_location, "*spoof*")); } +namespace { + +// A blink::mojom::Blob implementation that, when Load() is called, responds +// with a redirect to `redirect_target_` instead of a blob body. This simulates +// a Blob endpoint that does not behave like a real blob. +class RedirectingBlob : public blink::mojom::Blob { + public: + explicit RedirectingBlob(const GURL& redirect_target) + : redirect_target_(redirect_target) {} + + mojo::PendingRemote<blink::mojom::Blob> BindNewPipeAndPassRemote() { + mojo::PendingRemote<blink::mojom::Blob> remote; + receivers_.Add(this, remote.InitWithNewPipeAndPassReceiver()); + return remote; + } + + // blink::mojom::Blob: + void Clone(mojo::PendingReceiver<blink::mojom::Blob> receiver) override { + receivers_.Add(this, std::move(receiver)); + } + void AsDataPipeGetter( + mojo::PendingReceiver<network::mojom::DataPipeGetter>) override { + NOTREACHED(); + } + void ReadAll(mojo::ScopedDataPipeProducerHandle, + mojo::PendingRemote<blink::mojom::BlobReaderClient>) override { + NOTREACHED(); + } + void ReadRange(uint64_t, + uint64_t, + mojo::ScopedDataPipeProducerHandle, + mojo::PendingRemote<blink::mojom::BlobReaderClient>) override { + NOTREACHED(); + } + void Load( + mojo::PendingReceiver<network::mojom::URLLoader> loader, + const std::string& method, + const net::HttpRequestHeaders&, + mojo::PendingRemote<network::mojom::URLLoaderClient> client) override { + loader_receiver_ = std::move(loader); + client_.reset(); + client_.Bind(std::move(client)); + net::RedirectInfo redirect_info; + redirect_info.status_code = net::HTTP_FOUND; + redirect_info.new_method = method; + redirect_info.new_url = redirect_target_; + redirect_info.new_site_for_cookies = + net::SiteForCookies::FromUrl(redirect_target_); + auto head = network::mojom::URLResponseHead::New(); + head->headers = net::HttpResponseHeaders::TryToCreate( + "HTTP/1.1 302 Found\r\nLocation: " + redirect_target_.spec() + "\r\n"); + head->encoded_data_length = 0; + head->bypass_redirect_checks = true; + client_->OnReceiveRedirect(redirect_info, std::move(head)); + } + void ReadSideData(ReadSideDataCallback) override { NOTREACHED(); } + void CaptureSnapshot(CaptureSnapshotCallback callback) override { + std::move(callback).Run(0, std::nullopt); + } + void GetInternalUUID(GetInternalUUIDCallback callback) override { + std::move(callback).Run(""); + } + + private: + const GURL redirect_target_; + mojo::ReceiverSet<blink::mojom::Blob> receivers_; + mojo::PendingReceiver<network::mojom::URLLoader> loader_receiver_; + mojo::Remote<network::mojom::URLLoaderClient> client_; +}; + +} // namespace + +// A blob never serves a redirect, so a navigation to a blob URL whose +// underlying Blob endpoint replies with OnReceiveRedirect must not follow the +// redirect, regardless of any flags carried in the response head. +IN_PROC_BROWSER_TEST_F(BlobUrlBrowserTest, + NavigationToBlobUrlDoesNotFollowRedirect) { + GURL url = embedded_test_server()->GetURL("a.test", "/title1.html"); + url::Origin origin = url::Origin::Create(url); + ASSERT_TRUE(NavigateToURL(shell(), url)); + + RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>( + shell()->web_contents()->GetPrimaryMainFrame()); + + const GURL redirect_target("data:text/html,redirected"); + RedirectingBlob blob(redirect_target); + + const GURL blob_url("blob:" + origin.Serialize() + + "/33221100-0000-0000-0000-000000000000"); + static_cast<StoragePartitionImpl*>(rfh->GetStoragePartition()) + ->GetBlobUrlRegistry() + ->AddUrlMapping(blob_url, blob.BindNewPipeAndPassRemote(), + blink::StorageKey::CreateFirstParty(origin), origin, + rfh->GetProcess()->GetDeprecatedID()); + + NavigationHandleObserver observer(shell()->web_contents(), blob_url); + EXPECT_FALSE(NavigateToURL(shell(), blob_url)); + EXPECT_TRUE(observer.is_error()); + EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, observer.net_error_code()); + EXPECT_NE(redirect_target, shell()->web_contents()->GetLastCommittedURL()); +} + IN_PROC_BROWSER_TEST_F(BlobUrlBrowserTest, TestUseCounterForCrossPartitionSameOriginBlobURLFetch) { GURL main_url = embedded_test_server()->GetURL( diff --git a/content/browser/loader/navigation_url_loader_impl.cc b/content/browser/loader/navigation_url_loader_impl.cc index 715a69d7..f4d5647 100644 --- a/content/browser/loader/navigation_url_loader_impl.cc +++ b/content/browser/loader/navigation_url_loader_impl.cc @@ -1594,8 +1594,11 @@ ? head->bypass_redirect_checks : bypass_redirect_checks_; - if (!bypass_redirect_checks && - !IsSafeRedirectTarget(url_, redirect_info.new_url)) { + if (url_.SchemeIsBlob()) { + // Loading a blob URL never produces a redirect. + error = net::ERR_UNSAFE_REDIRECT; + } else if (!bypass_redirect_checks && + !IsSafeRedirectTarget(url_, redirect_info.new_url)) { error = net::ERR_UNSAFE_REDIRECT; } else if (--redirect_limit_ == 0) { error = net::ERR_TOO_MANY_REDIRECTS;
Regression Test / PoC
diff --git a/content/browser/blob_storage/blob_url_browsertest.cc b/content/browser/blob_storage/blob_url_browsertest.cc
index 03a79efe..8e96df8 100644
--- a/content/browser/blob_storage/blob_url_browsertest.cc
+++ b/content/browser/blob_storage/blob_url_browsertest.cc
@@ -15,6 +15,7 @@
#include "content/browser/devtools/render_frame_devtools_agent_host.h"
#include "content/browser/permissions/permission_controller_impl.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/storage_partition_impl.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/browser/browser_context.h"
#include "content/public/common/content_switches.h"
@@ -24,17 +25,26 @@
#include "content/public/test/content_browser_test.h"
#include "content/public/test/content_browser_test_content_browser_client.h"
#include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/navigation_handle_observer.h"
#include "content/public/test/test_devtools_protocol_client.h"
#include "content/public/test/test_utils.h"
#include "content/shell/browser/shell.h"
#include "content/test/content_browser_test_utils_internal.h"
+#include "mojo/public/cpp/bindings/receiver_set.h"
+#include "mojo/public/cpp/bindings/remote.h"
#include "net/base/net_errors.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/http/http_response_headers.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "net/url_request/redirect_info.h"
+#include "services/network/public/mojom/url_loader.mojom.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
+#include "storage/browser/blob/blob_url_registry.h"
#include "storage/browser/blob/features.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"
+#include "third_party/blink/public/mojom/blob/blob.mojom.h"
#include "url/gurl.h"
#include "url/origin.h"
@@ -229,6 +239,108 @@
EXPECT_FALSE(base::MatchPattern(window_location, "*spoof*"));
}
+namespace {
+
+// A blink::mojom::Blob implementation that, when Load() is called, responds
+// with a redirect to `redirect_target_` instead of a blob body. This simulates
+// a Blob endpoint that does not behave like a real blob.
+class RedirectingBlob : public blink::mojom::Blob {
+ public:
+ explicit RedirectingBlob(const GURL& redirect_target)
+ : redirect_target_(redirect_target) {}
+
+ mojo::PendingRemote<blink::mojom::Blob> BindNewPipeAndPassRemote() {
+ mojo::PendingRemote<blink::mojom::Blob> remote;
+ receivers_.Add(this, remote.InitWithNewPipeAndPassReceiver());
+ return remote;
+ }
+
+ // blink::mojom::Blob:
+ void Clone(mojo::PendingReceiver<blink::mojom::Blob> receiver) override {
+ receivers_.Add(this, std::move(receiver));
+ }
+ void AsDataPipeGetter(
+ mojo::PendingReceiver<network::mojom::DataPipeGetter>) override {
+ NOTREACHED();
+ }
+ void ReadAll(mojo::ScopedDataPipeProducerHandle,
+ mojo::PendingRemote<blink::mojom::BlobReaderClient>) override {
+ NOTREACHED();
+ }
+ void ReadRange(uint64_t,
+ uint64_t,
+ mojo::ScopedDataPipeProducerHandle,
+ mojo::PendingRemote<blink::mojom::BlobReaderClient>) override {
+ NOTREACHED();
+ }
+ void Load(
+ mojo::PendingReceiver<network::mojom::URLLoader> loader,
+ const std::string& method,
+ const net::HttpRequestHeaders&,
+ mojo::PendingRemote<network::mojom::URLLoaderClient> client) override {
+ loader_receiver_ = std::move(loader);
+ client_.reset();
+ client_.Bind(std::move(client));
+ net::RedirectInfo redirect_info;
+ redirect_info.status_code = net::HTTP_FOUND;
+ redirect_info.new_method = method;
+ redirect_info.new_url = redirect_target_;
+ redirect_info.new_site_for_cookies =
+ net::SiteForCookies::FromUrl(redirect_target_);
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = net::HttpResponseHeaders::TryToCreate(
+ "HTTP/1.1 302 Found\r\nLocation: " + redirect_target_.spec() + "\r\n");
+ head->encoded_data_length = 0;
+ head->bypass_redirect_checks = true;
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+ void ReadSideData(ReadSideDataCallback) override { NOTREACHED(); }
+ void CaptureSnapshot(CaptureSnapshotCallback callback) override {
+ std::move(callback).Run(0, std::nullopt);
+ }
+ void GetInternalUUID(GetInternalUUIDCallback callback) override {
+ std::move(callback).Run("");
+ }
+
+ private:
+ const GURL redirect_target_;
+ mojo::ReceiverSet<blink::mojom::Blob> receivers_;
+ mojo::PendingReceiver<network::mojom::URLLoader> loader_receiver_;
+ mojo::Remote<network::mojom::URLLoaderClient> client_;
+};
+
+} // namespace
+
+// A blob never serves a redirect, so a navigation to a blob URL whose
+// underlying Blob endpoint replies with OnReceiveRedirect must not follow the
+// redirect, regardless of any flags carried in the response head.
+IN_PROC_BROWSER_TEST_F(BlobUrlBrowserTest,
+ NavigationToBlobUrlDoesNotFollowRedirect) {
+ GURL url = embedded_test_server()->GetURL("a.test", "/title1.html");
+ url::Origin origin = url::Origin::Create(url);
+ ASSERT_TRUE(NavigateToURL(shell(), url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+
+ const GURL redirect_target("data:text/html,redirected");
+ RedirectingBlob blob(redirect_target);
+
+ const GURL blob_url("blob:" + origin.Serialize() +
+ "/33221100-0000-0000-0000-000000000000");
+ static_cast<StoragePartitionImpl*>(rfh->GetStoragePartition())
+ ->GetBlobUrlRegistry()
+ ->AddUrlMapping(blob_url, blob.BindNewPipeAndPassRemote(),
+ blink::StorageKey::CreateFirstParty(origin), origin,
+ rfh->GetProcess()->GetDeprecatedID());
+
+ NavigationHandleObserver observer(shell()->web_contents(), blob_url);
+ EXPECT_FALSE(NavigateToURL(shell(), blob_url));
+ EXPECT_TRUE(observer.is_error());
+ EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, observer.net_error_code());
+ EXPECT_NE(redirect_target, shell()->web_contents()->GetLastCommittedURL());
+}
+
IN_PROC_BROWSER_TEST_F(BlobUrlBrowserTest,
TestUseCounterForCrossPartitionSameOriginBlobURLFetch) {
GURL main_url = embedded_test_server()->GetURL(
Original Bug Report
Bypass of navigation redirect checks via renderer-hosted Blob implementation
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 malicious Blob implementation that receives a browser-process URLLoaderClient during navigation. This allows the renderer to trigger redirects with the ‘bypass_redirect_checks’ flag enabled, bypassing security gates that normally prevent navigations to restricted schemes like file://.
Affected files:
storage/browser/blob/blob_url_loader_factory.ccstorage/browser/blob/blob_url_store_impl.cccontent/browser/loader/navigation_url_loader_impl.ccstorage/browser/blob/blob_url_registry.cccontent/browser/renderer_host/navigation_request.cc
Estimated timestamp from git blame: 2025-07-10
Summary
A trust boundary violation in the Blob URL handling logic potentially allows a compromised renderer to bypass critical browser-process security checks during navigation. By registering a malicious blink::mojom::Blob implementation, an attacker can receive a network::mojom::URLLoaderClient remote from the browser process. Using this remote, the attacker can force the browser to redirect a navigation to restricted schemes (e.g., file://, chrome://) by setting the bypass_redirect_checks flag in the URLResponseHead, which is currently trusted by the browser.
Root Cause Analysis
1. Disclosure of URLLoaderClient to Renderer
When a navigation to a Blob URL occurs, BlobURLLoaderFactory::CreateLoaderAndStart (in storage/browser/blob/blob_url_loader_factory.cc) invokes Load on a blink::mojom::Blob remote previously registered by a renderer. Crucially, it passes the navigation’s URLLoaderClient remote directly to the renderer-controlled implementation:
// storage/browser/blob/blob_url_loader_factory.cc:80
blob_->Load(std::move(loader), request.method, request.headers,
std::move(client));
2. Bypassing IsSafeRedirectTarget
The compromised renderer, now holding the URLLoaderClient, can call OnReceiveRedirect. If it provides a URLResponseHead with bypass_redirect_checks = true, NavigationURLLoaderImpl::OnReceiveRedirect will skip the IsSafeRedirectTarget check, which is the primary gate preventing redirects to sensitive schemes. This flag is currently enabled by default via the kBypassRedirectChecksPerRequest feature.
// content/browser/loader/navigation_url_loader_impl.cc:1601
bool bypass_redirect_checks =
base::FeatureList::IsEnabled(features::kBypassRedirectChecksPerRequest)
? head->bypass_redirect_checks
: bypass_redirect_checks_;
if (!bypass_redirect_checks &&
!IsSafeRedirectTarget(url_, redirect_info.new_url)) {
error = net::ERR_UNSAFE_REDIRECT;
}
Potential Trigger Steps
- A compromised renderer registers a malicious
blink::mojom::Blobremote with the browser’sBlobURLStoreImplfor a specific Blob URL. - The attacker triggers a browser-initiated navigation to this URL (e.g., by creating a history entry and inducing the user to click the ‘Back’ button).
NavigationURLLoaderImplstarts the navigation and usesBlobURLLoaderFactoryto load the resource.- The browser passes its
URLLoaderClientto the malicious renderer implementation. - The renderer calls
OnReceiveRedirecton the client, specifying a restricted target (e.g.,file:///etc/passwd) and settingbypass_redirect_checks = truein the response head. NavigationURLLoaderImplaccepts the redirect and bypasses theIsSafeRedirectTargetcheck.NavigationRequestfollows the redirect, bypassingCanRequestURLbecause the navigation is browser-initiated.
Security Impact
This vulnerability represents a significant bypass of browser-process security logic and a failure to maintain the Mojo trust boundary between the renderer and the browser. While Site Isolation provides a layer of protection by committing sensitive content in an isolated process, the ability to bypass navigation policy gates allows an attacker to drive privileged browser-process navigation logic and bypass scheme-based access controls.
Suggested Fix
NavigationURLLoaderImpl should not trust the bypass_redirect_checks flag when it is provided by an untrusted source, such as a renderer-hosted Blob implementation. The browser should either ignore this flag in URLResponseHead for such requests or implement stricter validation on the redirects initiated by renderers through the URLLoaderClient interface.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.