Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Loader
DescriptionInsufficient validation of untrusted input in Loader
ComponentLoader
Bug ClassLogic Error
Tracker497030032
Fix commit1551caa4af5f (chromium/src) +118/-37
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
switch
content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
modified

Files Changed

  • content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
  • content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
From 1551caa4af5f70812e3433418d68ecf0217ddb54 Mon Sep 17 00:00:00 2001
From: Mike West <mkwst@chromium.org>
Date: Fri, 10 Apr 2026 01:07:21 -0700
Subject: [PATCH] Harden `SubresourceSignedExchangeURLLoaderFactory::CreateLoaderAndStart`

Rather than DCHECKing a match between the Signed Exchange's inner URL
and the URL requested, this patch shifts to an explicit check along with
`ReportBadMessage(...)`, mitigating the risk of confusion. It also
shifts the implementation of `IsValidRequestInitiator(...)` to return
false for incompatible locks, meaning they'll also fall into a path that
safely reports a bad message.

Bug: 497030032
Change-Id: Icd12f8fcf2eb5f869e5fe9ddf4f440e572e7cc20
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7724821
Commit-Queue: Mike West <mkwst@chromium.org>
Reviewed-by: Kouhei Ueno <kouhei@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1612679}
---

diff --git a/content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc b/content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
index 2127102..776e0f0 100644
--- a/content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
+++ b/content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
@@ -11,8 +11,6 @@
 
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
-#include "base/notreached.h"
-#include "base/time/time.h"
 #include "content/browser/web_package/signed_exchange_inner_response_url_loader.h"
 #include "mojo/public/cpp/bindings/message.h"
 #include "mojo/public/cpp/bindings/remote.h"
@@ -37,30 +35,17 @@
       network::VerifyRequestInitiatorLock(request_initiator_origin_lock,
                                           request.request_initiator);
   switch (initiator_lock_compatibility) {
-    case network::InitiatorLockCompatibility::kBrowserProcess:
-      // kBrowserProcess cannot happen outside of NetworkService.
-      NOTREACHED();
-
-    case network::InitiatorLockCompatibility::kNoLock:
-    case network::InitiatorLockCompatibility::kNoInitiator:
-      // Only browser-initiated navigations can specify no initiator and we only
-      // expect subresource requests (i.e. non-navigations) to go through
-      // SubresourceSignedExchangeURLLoaderFactory::CreateLoaderAndStart.
-      NOTREACHED();
-
     case network::InitiatorLockCompatibility::kCompatibleLock:
       return true;
 
+    case network::InitiatorLockCompatibility::kBrowserProcess:
+    case network::InitiatorLockCompatibility::kNoLock:
+    case network::InitiatorLockCompatibility::kNoInitiator:
     case network::InitiatorLockCompatibility::kIncorrectLock:
-      // This branch indicates that either 1) the CreateLoaderAndStart IPC was
-      // forged by a malicious/compromised renderer process or 2) there are
-      // renderer-side bugs.
-      NOTREACHED();
+      return false;
   }
 
-  // Failing safely for an unrecognied `network::InitiatorLockCompatibility`
-  // enum value.
-  NOTREACHED();
+  return false;
 }
 
 }  // namespace
@@ -98,10 +83,20 @@
     mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
         ->OnComplete(
             network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
-    NOTREACHED();
+    return;
   }
 
-  DCHECK_EQ(request.url, entry_->inner_url());
+  if (request.url != entry_->inner_url()) {
+    network::debug::ScopedResourceRequestCrashKeys request_crash_keys(request);
+    mojo::ReportBadMessage(
+        "SubresourceSignedExchangeURLLoaderFactory: "
+        "request.url does not match inner_url");
+    mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+        ->OnComplete(
+            network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
+    return;
+  }
+
   mojo::MakeSelfOwnedReceiver(
       std::make_unique<SignedExchangeInnerResponseURLLoader>(
           request, entry_->inner_response().Clone(),
diff --git a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
index 5d893be..87ba0601 100644
--- a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
+++ b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
@@ -5,10 +5,14 @@
 #include "content/browser/web_package/subresource_signed_exchange_url_loader_factory.h"
 
 #include <memory>
+#include <string>
 #include <utility>
 
+#include "base/functional/callback_helpers.h"
+#include "base/test/bind.h"
 #include "content/browser/web_package/prefetched_signed_exchange_cache.h"
 #include "content/public/test/browser_task_environment.h"
+#include "mojo/public/cpp/system/functions.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/url_loader.mojom.h"
@@ -21,21 +25,14 @@
 namespace content {
 namespace {
 
-// This is a regression test for https://crbug.com/345261068.
-// (Note that the repro may require `enable_dangling_raw_ptr_checks` in
-// `args.gn`. See also `//docs/dangling_ptr_guide.md`)
-TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
-     ShortlivedFactoryAndLonglivedLoader) {
-  BrowserTaskEnvironment task_environment;
-  GURL inner_url("https://foo.com/outer/inner");
-  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
-
-  // Construct a minimal `PrefetchedSignedExchangeCacheEntry` needed to make the
-  // unit tests work.
+std::unique_ptr<PrefetchedSignedExchangeCacheEntry> CreateCacheEntry(
+    const GURL& outer_url,
+    const GURL& inner_url,
+    storage::BlobStorageContext* blob_context) {
   auto entry = std::make_unique<PrefetchedSignedExchangeCacheEntry>();
   auto status = std::make_unique<network::URLLoaderCompletionStatus>();
   entry->SetCompletionStatus(std::move(status));
-  entry->SetOuterUrl(GURL("https://foo.com/outer"));
+  entry->SetOuterUrl(outer_url);
   entry->SetInnerUrl(inner_url);
   auto headers = base::MakeRefCounted<net::HttpResponseHeaders>(
       net::HttpUtil::AssembleRawHeaders(
@@ -48,12 +45,26 @@
   auto inner_response = network::mojom::URLResponseHead::New();
   inner_response->headers = headers;
   entry->SetInnerResponse(std::move(inner_response));
-  storage::BlobStorageContext blob_context;
   std::unique_ptr<storage::BlobDataHandle> blob_handle =
-      blob_context.AddBrokenBlob("broken_uuid", "", "",
-                                 storage::BlobStatus::ERR_OUT_OF_MEMORY);
+      blob_context->AddBrokenBlob("broken_uuid", "", "",
+                                  storage::BlobStatus::ERR_OUT_OF_MEMORY);
   entry->SetBlobDataHandle(std::move(blob_handle));
   entry->SetSignatureExpireTime(base::Time::Now() + base::Days(1));
+  return entry;
+}
+
+// This is a regression test for https://crbug.com/345261068.
+// (Note that the repro may require `enable_dangling_raw_ptr_checks` in
+// `args.gn`. See also `//docs/dangling_ptr_guide.md`)
+TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
+     ShortlivedFactoryAndLonglivedLoader) {
+  BrowserTaskEnvironment task_environment;
+  GURL inner_url("https://foo.com/outer/inner");
+  GURL outer_url("https://foo.com/outer");
+  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
+
+  storage::BlobStorageContext blob_context;
+  auto entry = CreateCacheEntry(outer_url, inner_url, &blob_context);
 
   // Create a `SubresourceSignedExchangeURLLoaderFactory`.
   //
@@ -86,5 +97,80 @@
   loader.reset();
 }
 
+TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
+     CreateLoaderAndStart_InvalidURL) {
+  BrowserTaskEnvironment task_environment;
+  GURL inner_url("https://foo.com/outer/inner");
+  GURL outer_url("https://foo.com/outer");
+  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
+
+  storage::BlobStorageContext blob_context;
+  auto entry = CreateCacheEntry(outer_url, inner_url, &blob_context);
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory;
+  new content::SubresourceSignedExchangeURLLoaderFactory(
+      factory.BindNewPipeAndPassReceiver(), std::move(entry),
+      /* request_initiator_origin_lock = */ initiator_origin);
+
+  std::string received_error;
+  mojo::SetDefaultProcessErrorHandler(base::BindLambdaForTesting(
+      [&](const std::string& error) { received_error = error; }));
+
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+  network::ResourceRequest request;
+  request.url = GURL("https://attacker.com/fake.json");
+  request.request_initiator = initiator_origin;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), 123,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
index 5d893be..87ba0601 100644
--- a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
+++ b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc
@@ -5,10 +5,14 @@
 #include "content/browser/web_package/subresource_signed_exchange_url_loader_factory.h"
 
 #include <memory>
+#include <string>
 #include <utility>
 
+#include "base/functional/callback_helpers.h"
+#include "base/test/bind.h"
 #include "content/browser/web_package/prefetched_signed_exchange_cache.h"
 #include "content/public/test/browser_task_environment.h"
+#include "mojo/public/cpp/system/functions.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/url_loader.mojom.h"
@@ -21,21 +25,14 @@
 namespace content {
 namespace {
 
-// This is a regression test for https://crbug.com/345261068.
-// (Note that the repro may require `enable_dangling_raw_ptr_checks` in
-// `args.gn`. See also `//docs/dangling_ptr_guide.md`)
-TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
-     ShortlivedFactoryAndLonglivedLoader) {
-  BrowserTaskEnvironment task_environment;
-  GURL inner_url("https://foo.com/outer/inner");
-  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
-
-  // Construct a minimal `PrefetchedSignedExchangeCacheEntry` needed to make the
-  // unit tests work.
+std::unique_ptr<PrefetchedSignedExchangeCacheEntry> CreateCacheEntry(
+    const GURL& outer_url,
+    const GURL& inner_url,
+    storage::BlobStorageContext* blob_context) {
   auto entry = std::make_unique<PrefetchedSignedExchangeCacheEntry>();
   auto status = std::make_unique<network::URLLoaderCompletionStatus>();
   entry->SetCompletionStatus(std::move(status));
-  entry->SetOuterUrl(GURL("https://foo.com/outer"));
+  entry->SetOuterUrl(outer_url);
   entry->SetInnerUrl(inner_url);
   auto headers = base::MakeRefCounted<net::HttpResponseHeaders>(
       net::HttpUtil::AssembleRawHeaders(
@@ -48,12 +45,26 @@
   auto inner_response = network::mojom::URLResponseHead::New();
   inner_response->headers = headers;
   entry->SetInnerResponse(std::move(inner_response));
-  storage::BlobStorageContext blob_context;
   std::unique_ptr<storage::BlobDataHandle> blob_handle =
-      blob_context.AddBrokenBlob("broken_uuid", "", "",
-                                 storage::BlobStatus::ERR_OUT_OF_MEMORY);
+      blob_context->AddBrokenBlob("broken_uuid", "", "",
+                                  storage::BlobStatus::ERR_OUT_OF_MEMORY);
   entry->SetBlobDataHandle(std::move(blob_handle));
   entry->SetSignatureExpireTime(base::Time::Now() + base::Days(1));
+  return entry;
+}
+
+// This is a regression test for https://crbug.com/345261068.
+// (Note that the repro may require `enable_dangling_raw_ptr_checks` in
+// `args.gn`. See also `//docs/dangling_ptr_guide.md`)
+TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
+     ShortlivedFactoryAndLonglivedLoader) {
+  BrowserTaskEnvironment task_environment;
+  GURL inner_url("https://foo.com/outer/inner");
+  GURL outer_url("https://foo.com/outer");
+  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
+
+  storage::BlobStorageContext blob_context;
+  auto entry = CreateCacheEntry(outer_url, inner_url, &blob_context);
 
   // Create a `SubresourceSignedExchangeURLLoaderFactory`.
   //
@@ -86,5 +97,80 @@
   loader.reset();
 }
 
+TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
+     CreateLoaderAndStart_InvalidURL) {
+  BrowserTaskEnvironment task_environment;
+  GURL inner_url("https://foo.com/outer/inner");
+  GURL outer_url("https://foo.com/outer");
+  auto initiator_origin = url::Origin::Create(GURL("https://foo.com"));
+
+  storage::BlobStorageContext blob_context;
+  auto entry = CreateCacheEntry(outer_url, inner_url, &blob_context);
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory;
+  new content::SubresourceSignedExchangeURLLoaderFactory(
+      factory.BindNewPipeAndPassReceiver(), std::move(entry),
+      /* request_initiator_origin_lock = */ initiator_origin);
+
+  std::string received_error;
+  mojo::SetDefaultProcessErrorHandler(base::BindLambdaForTesting(
+      [&](const std::string& error) { received_error = error; }));
+
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+  network::ResourceRequest request;
+  request.url = GURL("https://attacker.com/fake.json");
+  request.request_initiator = initiator_origin;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), 123,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  factory.FlushForTesting();
+  EXPECT_EQ(received_error,
+            "SubresourceSignedExchangeURLLoaderFactory: "
+            "request.url does not match inner_url");
+
+  mojo::SetDefaultProcessErrorHandler(base::NullCallback());
+}
+
+TEST(SubresourceSignedExchangeURLLoaderFactoryTest,
+     CreateLoaderAndStart_InvalidInitiator) {
+  BrowserTaskEnvironment task_environment;
+  GURL inner_url("https://foo.com/outer/inner");
+  GURL outer_url("https://foo.com/outer");
+  auto initiator_origin_lock = url::Origin::Create(GURL("https://foo.com"));
+
+  storage::BlobStorageContext blob_context;
+  auto entry = CreateCacheEntry(outer_url, inner_url, &blob_context);
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory;
+  new content::SubresourceSignedExchangeURLLoaderFactory(
+      factory.BindNewPipeAndPassReceiver(), std::move(entry),
+      /* request_initiator_origin_lock = */ initiator_origin_lock);
+
+  std::string received_error;
+  mojo::SetDefaultProcessErrorHandler(base::BindLambdaForTesting(
+      [&](const std::string& error) { received_error = error; }));
+
+  mojo::Remote<network::mojom::URLLoader> loader;
+  network::TestURLLoaderClient client;
+  network::ResourceRequest request;
+  request.url = inner_url;
+  // Use a different origin than the lock.
+  request.request_initiator = url::Origin::Create(GURL("https://attacker.com"));
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), 123,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  factory.FlushForTesting();
+  EXPECT_EQ(received_error,
+            "SubresourceSignedExchangeURLLoaderFactory: "
+            "lock VS initiator mismatch");
+
+  mojo::SetDefaultProcessErrorHandler(base::NullCallback());
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential CORS/ORB bypass in SubresourceSignedExchangeURLLoaderFactory via spoofed request URL

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: In release builds, a compromised renderer can bypass CORS and Opaque Response Blocking (ORB) when loading prefetched Signed Exchanges. The SubresourceSignedExchangeURLLoaderFactory relies on a DCHECK to validate the request URL, allowing an attacker to spoof the URL in an IPC call and read cross-origin responses.

Affected files:

  • content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc
  • content/browser/web_package/signed_exchange_inner_response_url_loader.cc
  • content/browser/web_package/prefetched_signed_exchange_cache.cc

Estimated timestamp from git blame: 2024-06-18

Description

A potential vulnerability exists in SubresourceSignedExchangeURLLoaderFactory::CreateLoaderAndStart where a compromised renderer can bypass fundamental security checks (CORS and ORB) to read sensitive cross-origin data from prefetched Signed Exchanges (SXGs). This occurs because the browser process validates the requested URL against the SXG’s actual inner URL using a DCHECK_EQ. In release builds, this check is compiled out or does not terminate the process, allowing a compromised renderer to provide a spoofed URL that appears same-origin with its request initiator.

Technical Details

When a prefetched SXG subresource is prepared for navigation, the browser creates a SubresourceSignedExchangeURLLoaderFactory and passes its remote to the renderer process. The renderer then invokes CreateLoaderAndStart over IPC.

In content/browser/web_package/subresource_signed_exchange_url_loader_factory.cc:

// Lines 91-102: Validates the initiator against the origin lock (attacker origin).
if (!IsValidRequestInitiator(request, request_initiator_origin_lock_)) {
  // ... mojo::ReportBadMessage and return ...
}

// Line 104: Incorrectly uses DCHECK for URL validation.
DCHECK_EQ(request.url, entry_->inner_url());

An attacker controlling the renderer can craft an IPC call where both request.url and request.request_initiator are set to the attacker’s origin, bypassing the initiator check (since it matches the lock) and silently failing the DCHECK_EQ URL validation.

This spoofed request is passed to SignedExchangeInnerResponseURLLoader, leading to two security bypasses:

  1. CORS Bypass: The loader calls network::cors::ShouldCheckCors(request.url, request.request_initiator, request.mode). Because the spoofed URL and the initiator are same-origin (both attacker’s origin), it returns false, skipping the Access-Control-Allow-Origin check for the victim’s cross-origin response.
  2. ORB Bypass: The CrossOriginReadBlockingChecker initializes the ORB analyzer (OpaqueResponseBlockingAnalyzer::Init) with the spoofed URL. If the URL and initiator are same-origin, ORB returns Decision::kAllow, completely bypassing MIME sniffing and blocking of cross-origin sensitive data.

The browser process then pipes the cross-origin response body from the victim’s SXG to the compromised renderer via SendResponseBody(), violating Site Isolation guarantees.

(Note: These are potential steps based on code analysis, as our tooling cannot currently run a working proof of concept.)

Potential Attack Scenario

  1. Attacker Setup: The attacker hosts a victim’s publicly-distributed SXG (e.g., https://victim.com/sensitive.json from a CDN) and an attacker-signed main SXG on their server.
  2. Prefetch Trigger: A victim user navigates to an attacker-controlled page that triggers a prefetch of the main SXG and the victim’s subresource SXG.
  3. Navigation Interception: The user navigates to the main SXG. The browser intercepts the navigation, creates a SubresourceSignedExchangeURLLoaderFactory for the victim’s subresource, and sends its remote to the renderer.
  4. Compromised Renderer: The attacker achieves code execution in the renderer process hosting the attacker’s main SXG.
  5. Malicious IPC Invocation: The compromised renderer invokes CreateLoaderAndStart on the factory, spoofing both request.url and request.request_initiator to be the attacker’s origin (https://attacker.com/fake.json).
  6. Security Checks Bypassed: The browser process validates the initiator against the lock (which passes) but skips the URL validation due to the DCHECK. CORS and ORB checks subsequently evaluate the request as same-origin (attacker to attacker) and allow the request.
  7. Data Leak: The loader streams the victim’s sensitive cross-origin data to the compromised renderer, completing the Site Isolation bypass.

Suggested Fix

Replace the DCHECK_EQ in SubresourceSignedExchangeURLLoaderFactory::CreateLoaderAndStart with a proper security check that terminates the request and reports a bad message if the URL does not match the SXG’s inner URL.

  if (request.url != entry_->inner_url()) {
    mojo::ReportBadMessage(
        "SubresourceSignedExchangeURLLoaderFactory: "
        "request.url does not match inner_url");
    mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
        ->OnComplete(
            network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
    return;
  }

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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