Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Navigation
DescriptionInsufficient validation of untrusted input in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker513795122
Fix commitd103c7ce9b9d (chromium/src) +117/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-14

Changed Functions

FunctionChangeNotes
RedirectingBlob
content/browser/blob_storage/blob_url_browsertest.cc
modified

Files Changed

  • content/browser/blob_storage/blob_url_browsertest.cc
  • content/browser/loader/navigation_url_loader_impl.cc
From d103c7ce9b9d0fd34a59c0b46647c6db6da5aaa5 Mon Sep 17 00:00:00 2001
From: Test User <chikamune@google.com>
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 <chikamune@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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(
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • storage/browser/blob/blob_url_store_impl.cc
  • content/browser/loader/navigation_url_loader_impl.cc
  • storage/browser/blob/blob_url_registry.cc
  • content/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

  1. A compromised renderer registers a malicious blink::mojom::Blob remote with the browser’s BlobURLStoreImpl for a specific Blob URL.
  2. 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).
  3. NavigationURLLoaderImpl starts the navigation and uses BlobURLLoaderFactory to load the resource.
  4. The browser passes its URLLoaderClient to the malicious renderer implementation.
  5. The renderer calls OnReceiveRedirect on the client, specifying a restricted target (e.g., file:///etc/passwd) and setting bypass_redirect_checks = true in the response head.
  6. NavigationURLLoaderImpl accepts the redirect and bypasses the IsSafeRedirectTarget check.
  7. NavigationRequest follows the redirect, bypassing CanRequestURL because 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.

View on issue tracker