Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Network
DescriptionMissing authorization in Network
ComponentNetwork
Sub-componentPrefetch
Bug ClassAuthorization Error
Tracker498737756
Fix commit3ea5ba00721b (chromium/src) +612/-60
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
content/browser/loader/prefetch_url_loader_service_context.cc
modified
BrowserContext
content/browser/loader/prefetch_url_loader_service_context.h
modified
if
content/browser/loader/subresource_proxying_url_loader_service.cc
modified

Files Changed

  • content/browser/loader/subresource_proxying_url_loader_service.h
  • content/browser/loader/prefetch_url_loader_service_context.cc
  • content/browser/loader/prefetch_browsertest.cc
  • tools/metrics/histograms/metadata/prefetch/enums.xml
  • tools/metrics/histograms/metadata/prefetch/histograms.xml
  • content/browser/loader/subresource_proxying_url_loader_service.cc
  • content/browser/loader/prefetch_url_loader_service_context.h
From 3ea5ba00721b6ba23b76ad63ff679de648284df3 Mon Sep 17 00:00:00 2001
From: Takashi Toyoshima <toyoshim@chromium.org>
Date: Tue, 01 Sep 2026 13:58:41 -0700
Subject: [PATCH] Prefetch: Tighten recursive prefetch token checks and LRU-bound cache

When a document receives a cross-origin prefetch response, the browser
mints a recursive-prefetch token that maps to the destination's
IsolationInfo. This allows the renderer to prefetch recursive
subresources under the target's isolated network partition.

This CL addresses two issues in recursive prefetch handling:

1. Origin Validation: Enforces that recursive prefetches using a token
   cannot target the initiating document's own origin. A request
   targeting the initiator's origin violates the invariant and is
   rejected with a bad message to prevent origin confusion across
   network partitions.
2. LRU Bound: Replaces the unbounded token map with base::LRUCache
   (capped at 16 entries) in
   SubresourceProxyingURLLoaderService::BindContext.
   This bounds browser memory usage while preventing token starvation by
   evicting older entries when new document prefetches arrive.
3. Encapsulation: Moves GenerateRecursivePrefetchToken to an anonymous
   namespace helper in the .cc file.
4. Telemetry: Adds UMA metrics
   (Prefetch.RecursivePrefetch.TokenLookupResult and
   Prefetch.RecursivePrefetch.TokensPerDocument) to monitor cache hit,
   eviction, and distribution in the wild.
5. Testing: Adds browser tests in PrefetchRecursiveBrowserTest covering
   cross-origin allowance, same-origin rejection, invalid/evicted token
   rejection, and LRU eviction.

Bug: 498737756
Change-Id: Iac8bd5fba2c2313325187b14fdaece8a7950170c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8278451
Reviewed-by: Hiroki Nakagawa <nhiroki@chromium.org>
Commit-Queue: Takashi Toyoshima <toyoshim@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1690103}
---
diff --git a/content/browser/loader/prefetch_url_loader_service_context.cc b/content/browser/loader/prefetch_url_loader_service_context.cc
index 779902a..c2bda10 100644
--- a/content/browser/loader/prefetch_url_loader_service_context.cc
+++ b/content/browser/loader/prefetch_url_loader_service_context.cc
@@ -4,6 +4,8 @@
 
 #include "content/browser/loader/prefetch_url_loader_service_context.h"
 
+#include "base/metrics/histogram_functions.h"
+#include "base/unguessable_token.h"
 #include "content/browser/loader/prefetch_url_loader.h"
 #include "content/browser/renderer_host/frame_tree_node.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
@@ -16,6 +18,51 @@
 #include "third_party/blink/public/common/renderer_preferences/renderer_preferences.h"
 
 namespace content {
+namespace {
+
+using BindContext = SubresourceProxyingURLLoaderService::BindContext;
+
+base::UnguessableToken GenerateRecursivePrefetchToken(
+    base::WeakPtr<BindContext> current_context,
+    const network::ResourceRequest& request) {
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  // If the relevant frame has gone away before this method is called
+  // asynchronously, we cannot generate and store a {token, IsolationInfo} pair
+  // in the frame's `prefetch_isolation_infos` LRU cache, so we'll create and
+  // return a dummy token that will not get used.
+  if (!current_context) {
+    return base::UnguessableToken::Create();
+  }
+
+  // Attach the fenced frame nonce to the request's IsolationInfo. If the nonce
+  // is marked revoked for untrusted network access, the request will either not
+  // be created due to the check in `CorsURLLoaderFactory::CreateLoaderAndStart`
+  // or cancelled due to the check
+  // `CancelRequestIfNonceMatchesAndUrlNotExempted`.
+  // TODO(crbug.com/349978810): Attach credentialless iframe nonce.
+  std::optional<base::UnguessableToken> fenced_frame_nonce =
+      current_context->render_frame_host
+          ? current_context->render_frame_host->frame_tree_node()
+                ->GetFencedFrameNonce()
+          : std::nullopt;
+
+  // Create IsolationInfo.
+  url::Origin destination_origin = url::Origin::Create(request.url);
+  net::IsolationInfo preload_isolation_info = net::IsolationInfo::Create(
+      net::IsolationInfo::RequestType::kOther, destination_origin,
+      destination_origin, net::SiteForCookies(), /*nonce=*/fenced_frame_nonce);
+
+  // Generate token.
+  base::UnguessableToken return_token = base::UnguessableToken::Create();
+
+  // Associate the two in the LRU cache, and return the token.
+  current_context->total_tokens_generated++;
+  current_context->prefetch_isolation_infos.Put(
+      return_token, preload_isolation_info);
+  return return_token;
+}
+
+}  // namespace
 
 PrefetchURLLoaderServiceContext::PrefetchURLLoaderServiceContext(
     BrowserContext* browser_context,
@@ -84,7 +131,15 @@
 
   if (resource_request.load_flags &
       net::LOAD_RESTRICTED_PREFETCH_FOR_MAIN_FRAME) {
-    CHECK(!resource_request.recursive_prefetch_token);
+    if (resource_request.recursive_prefetch_token) {
+      loader_factory_receivers_->ReportBadMessage(
+          "Prefetch/CreatePrefetchLoaderAndStart: recursive token with "
+          "restricted main frame prefetch");
+      mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+          ->OnComplete(
+              network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
+      return;
+    }
     // The renderer has marked this prefetch as restricted, meaning it is a
     // cross-origin prefetch intended for top-level navigation reuse. We must
     // verify that the request meets the necessary security requirements, and
@@ -128,28 +183,81 @@
   } else if (resource_request.recursive_prefetch_token) {
     // Recursive prefetch from a cross-origin main resource prefetch.
 
-    // TODO(crbug.com/40150754): Figure out why we're seeing this condition
-    // hold true in the field.
-    if (!current_context.cross_origin_factory) {
-      return;
-    }
-
-    // Resurrect the request's IsolationInfo from the current context's map,
-    // and use it for this request.
+    // Resurrect the request's IsolationInfo from the current context's LRU
+    // cache, and use it for this request.
     auto isolation_info_iterator =
-        current_context.prefetch_isolation_infos.find(
+        current_context.prefetch_isolation_infos.Get(
             resource_request.recursive_prefetch_token.value());
 
     // An unexpected token could indicate a compromised renderer trying to
     // fetch a request in a special way. We'll cancel the request.
     if (isolation_info_iterator ==
         current_context.prefetch_isolation_infos.end()) {
+      base::UmaHistogramEnumeration(
+          "Prefetch.RecursivePrefetch.TokenLookupResult",
+          RecursivePrefetchTokenLookupResult::kTokenNotFound);
       mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
           ->OnComplete(
               network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
       return;
     }
 
+    EnsureCrossOriginFactory();
+    if (!current_context.cross_origin_factory) {
+      mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+          ->OnComplete(network::URLLoaderCompletionStatus(net::ERR_FAILED));
+      return;
+    }
+
+    // All fetches need to have an associated request_initiator.
+    if (!resource_request.request_initiator) {
+      loader_factory_receivers_->ReportBadMessage(
+          "Prefetch/CreatePrefetchLoaderAndStart: no request_initiator");
+      mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+          ->OnComplete(
+              network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
+      return;
+    }
+
+    // The request initiator has to match the request_initiator_origin_lock - it
+    // has to be the same origin as the last committed origin in the frame.
+    CHECK(current_context.render_frame_host);
+    if (!resource_request.request_initiator->opaque() &&
+        resource_request.request_initiator.value() !=
+            current_context.render_frame_host->GetLastCommittedOrigin()) {
+      loader_factory_receivers_->ReportBadMessage(
+          "Prefetch/CreatePrefetchLoaderAndStart: frame origin mismatch");
+      mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+          ->OnComplete(
+              network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
+      return;
+    }
+
+    // Recursive prefetches are subresources of a cross-origin main-frame
+    // prefetch and are dispatched with that target's `IsolationInfo` via the
+    // trusted cross-origin factory. Require them to be cross-origin with the
+    // initiating document, mirroring `IsValidCrossOriginPrefetch()` above, so
+    // a document cannot use a token minted for another origin to fetch its own
+    // resources under that origin's network partition.
+    if (current_context.render_frame_host->GetLastCommittedOrigin()
+            .IsSameOriginWith(resource_request.url) ||
+        resource_request.request_initiator->IsSameOriginWith(
+            resource_request.url)) {
+      base::UmaHistogramEnumeration(
+          "Prefetch.RecursivePrefetch.TokenLookupResult",
+          RecursivePrefetchTokenLookupResult::kRejectedSameOrigin);
+      loader_factory_receivers_->ReportBadMessage(
+          "Prefetch/CreatePrefetchLoaderAndStart: initiator origin");
+      mojo::Remote<network::mojom::URLLoaderClient>(std::move(client))
+          ->OnComplete(
+              network::URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT));
+      return;
+    }
+
+    base::UmaHistogramEnumeration(
+        "Prefetch.RecursivePrefetch.TokenLookupResult",
+        RecursivePrefetchTokenLookupResult::kSuccess);
+
     // Cross-site prefetches shouldn't include SameSite cookies.
     resource_request.site_for_cookies = net::SiteForCookies();
 
@@ -184,8 +292,7 @@
       browser_context_, std::move(prefetched_signed_exchange_cache),
       accept_langs_,
       base::BindOnce(
-          &PrefetchURLLoaderServiceContext::GenerateRecursivePrefetchToken,
-          base::Unretained(this),
+          &GenerateRecursivePrefetchToken,
           current_context.weak_ptr_factory.GetWeakPtr()));
   auto* raw_loader = loader.get();
   prefetch_loader_receivers_.Add(raw_loader, std::move(receiver),
@@ -264,45 +371,6 @@
   SetAcceptLanguages(new_prefs.accept_languages);
 }
 
-base::UnguessableToken
-PrefetchURLLoaderServiceContext::GenerateRecursivePrefetchToken(
-    base::WeakPtr<BindContext> current_context,
-    const network::ResourceRequest& request) {
-  // If the relevant frame has gone away before this method is called
-  // asynchronously, we cannot generate and store a {token, IsolationInfo} pair
-  // in the frame's `prefetch_isolation_infos` map, so we'll create and return a
-  // dummy token that will not get used.
-  if (!current_context) {
-    return base::UnguessableToken::Create();
-  }
-
-  // Attach the fenced frame nonce to the request's IsolationInfo. If the nonce
-  // is marked revoked for untrusted network access, the request will either not
-  // be created due to the check in `CorsURLLoaderFactory::CreateLoaderAndStart`
-  // or cancelled due to the check
-  // `CancelRequestIfNonceMatchesAndUrlNotExempted`.
-  // TODO(crbug.com/349978810): Attach credentialless iframe nonce.
-  std::optional<base::UnguessableToken> fenced_frame_nonce =
-      current_context->render_frame_host
-          ? current_context->render_frame_host->frame_tree_node()
-                ->GetFencedFrameNonce()
-          : std::nullopt;
-
-  // Create IsolationInfo.
-  url::Origin destination_origin = url::Origin::Create(request.url);
-  net::IsolationInfo preload_isolation_info = net::IsolationInfo::Create(
-      net::IsolationInfo::RequestType::kOther, destination_origin,
-      destination_origin, net::SiteForCookies(), /*nonce=*/fenced_frame_nonce);
-
-  // Generate token.
-  base::UnguessableToken return_token = base::UnguessableToken::Create();
-
-  // Associate the two, and return the token.
-  current_context->prefetch_isolation_infos.insert(
-      {return_token, preload_isolation_info});
-  return return_token;
-}
-
 std::vector<std::unique_ptr<blink::URLLoaderThrottle>>
 PrefetchURLLoaderServiceContext::CreateURLLoaderThrottles(
     const network::ResourceRequest& request,
diff --git a/content/browser/loader/prefetch_url_loader_service_context.h b/content/browser/loader/prefetch_url_loader_service_context.h
index 7f32b5d..79cb117 100644
--- a/content/browser/loader/prefetch_url_loader_service_context.h
+++ b/content/browser/loader/prefetch_url_loader_service_context.h
@@ -21,6 +21,18 @@
 
 namespace content {
 
+// These values are persisted to logs. Entries should not be renumbered and
+// numeric values should never be reused.
+//
+// LINT.IfChange(RecursivePrefetchTokenLookupResult)
+enum class RecursivePrefetchTokenLookupResult {
+  kSuccess = 0,
+  kTokenNotFound = 1,
+  kRejectedSameOrigin = 2,
+  kMaxValue = kRejectedSameOrigin,
+};
+// LINT.ThenChange(//tools/metrics/histograms/metadata/prefetch/enums.xml:RecursivePrefetchTokenLookupResult)
+
 class BrowserContext;
 
 // Contains the data and functions to handle <link rel="prefetch"> requests.
@@ -71,10 +83,6 @@
   bool IsValidCrossOriginPrefetch(
       const network::ResourceRequest& resource_request);
 
-  base::UnguessableToken GenerateRecursivePrefetchToken(
-      base::WeakPtr<BindContext> bind_context,
-      const network::ResourceRequest& request);
-
   // blink::mojom::RendererPreferenceWatcher.
   void NotifyUpdate(const blink::RendererPreferences& new_prefs) override;
 
diff --git a/content/browser/loader/subresource_proxying_url_loader_service.cc b/content/browser/loader/subresource_proxying_url_loader_service.cc
index 1a9e272..4c436a80 100644
--- a/content/browser/loader/subresource_proxying_url_loader_service.cc
+++ b/content/browser/loader/subresource_proxying_url_loader_service.cc
@@ -6,6 +6,8 @@
 
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
+#include "base/metrics/histogram_functions.h"
+#include "base/numerics/safe_conversions.h"
 #include "content/browser/loader/prefetch_url_loader_service_context.h"
 #include "content/browser/loader/subresource_proxying_url_loader.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
@@ -30,7 +32,7 @@
     scoped_refptr<PrefetchedSignedExchangeCache>
         prefetched_signed_exchange_cache)
     : frame_tree_node_id(frame_tree_node_id),
-      factory(factory),
+      factory(std::move(factory)),
       render_frame_host(std::move(render_frame_host)),
       cross_origin_factory(nullptr),
       prefetched_signed_exchange_cache(
@@ -41,7 +43,14 @@
   document = committed_document;
 }
 
-SubresourceProxyingURLLoaderService::BindContext::~BindContext() = default;
+SubresourceProxyingURLLoaderService::BindContext::~BindContext() {
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  if (total_tokens_generated > 0) {
+    base::UmaHistogramCounts100(
+        "Prefetch.RecursivePrefetch.TokensPerDocument",
+        base::saturated_cast<int>(total_tokens_generated));
+  }
+}
 
 SubresourceProxyingURLLoaderService::SubresourceProxyingURLLoaderService(
     BrowserContext* browser_context)
diff --git a/content/browser/loader/subresource_proxying_url_loader_service.h b/content/browser/loader/subresource_proxying_url_loader_service.h
index c7648ebef..dabb8738 100644
--- a/content/browser/loader/subresource_proxying_url_loader_service.h
+++ b/content/browser/loader/subresource_proxying_url_loader_service.h
@@ -7,8 +7,10 @@
 
 #include <string>
 
+#include "base/containers/hashing_lru_cache.h"
 #include "base/memory/raw_ptr.h"
 #include "base/memory/ref_counted.h"
+#include "base/unguessable_token.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/browser/frame_tree_node_id.h"
 #include "content/public/browser/weak_document_ptr.h"
@@ -36,6 +38,8 @@
     : public network::mojom::URLLoaderFactory {
  public:
   struct CONTENT_EXPORT BindContext : public base::RefCounted<BindContext> {
+    static constexpr size_t kMaxPrefetchIsolationInfoEntries = 16;
+
     // `factory` is a clone of the default factory bundle for document
     // subresource requests.
     BindContext(FrameTreeNodeId frame_tree_node_id,
@@ -58,15 +62,20 @@
         prefetched_signed_exchange_cache;
 
     // This maps recursive prefetch tokens to IsolationInfos that they should be
-    // fetched with.
-    std::map<base::UnguessableToken, net::IsolationInfo>
-        prefetch_isolation_infos;
+    // fetched with. Bounded to a small LRU cache to limit memory usage while
+    // allowing in-flight / recent cross-origin document prefetches to resolve
+    // their recursive preload subresources.
+    base::HashingLRUCache<base::UnguessableToken, net::IsolationInfo>
+        prefetch_isolation_infos{kMaxPrefetchIsolationInfoEntries};
 
     // Upon NavigationRequest::DidCommitNavigation(), `document` will be set to
     // the document that this `BindContext` is associated with. It will become
     // null whenever the document navigates away.
     WeakDocumentPtr document;
 
+    // Total number of recursive prefetch tokens generated for this document.
+    size_t total_tokens_generated = 0;
+
     // This must be the last member.
     base::WeakPtrFactory<SubresourceProxyingURLLoaderService::BindContext>
         weak_ptr_factory{this};
diff --git a/tools/metrics/histograms/metadata/prefetch/enums.xml b/tools/metrics/histograms/metadata/prefetch/enums.xml
index 90104fb..9c17c32 100644
--- a/tools/metrics/histograms/metadata/prefetch/enums.xml
+++ b/tools/metrics/histograms/metadata/prefetch/enums.xml
@@ -69,6 +69,16 @@
 
 <!-- LINT.ThenChange(//content/browser/preloading/prefetch/prefetch_match_resolver.h:PrefetchPotentialCandidateServingResult) -->
 
+<!-- LINT.IfChange(RecursivePrefetchTokenLookupResult) -->
+
+<enum name="RecursivePrefetchTokenLookupResult">
+  <int value="0" label="kSuccess"/>
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/loader/prefetch_browsertest.cc b/content/browser/loader/prefetch_browsertest.cc
index 977527e..9e17d9a 100644
--- a/content/browser/loader/prefetch_browsertest.cc
+++ b/content/browser/loader/prefetch_browsertest.cc
@@ -12,11 +12,13 @@
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
+#include "base/test/metrics/histogram_tester.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
 #include "base/threading/thread_restrictions.h"
 #include "build/build_config.h"
 #include "content/browser/loader/prefetch_browsertest_base.h"
+#include "content/browser/loader/prefetch_url_loader_service_context.h"
 #include "content/browser/loader/subresource_proxying_url_loader_service.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/storage_partition_impl.h"
@@ -1287,6 +1289,403 @@
   EXPECT_EQ(1, request_counter->GetRequestCount());
 }
 
+class PrefetchRecursiveBrowserTest : public PrefetchBrowserTestBase {
+ public:
+  PrefetchRecursiveBrowserTest() = default;
+
+  void SetUpOnMainThread() override {
+    PrefetchBrowserTestBase::SetUpOnMainThread();
+    host_resolver()->AddRule("*", "127.0.0.1");
+  }
+
+ protected:
+  mojo::Remote<network::mojom::URLLoaderFactory> BindFactoryForMainFrame() {
+    RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+        shell()->web_contents()->GetPrimaryMainFrame());
+    StoragePartitionImpl* partition =
+        static_cast<StoragePartitionImpl*>(rfh->GetStoragePartition());
+    mojo::Remote<network::mojom::URLLoaderFactory> remote;
+    bind_context_ =
+        partition->GetSubresourceProxyingURLLoaderService()->GetFactory(
+            remote.BindNewPipeAndPassReceiver(), rfh->GetFrameTreeNodeId(),
+            proxied_factory_.GetSafeWeakWrapper(), rfh->GetWeakPtr(),
+            /*prefetched_signed_exchange_cache=*/nullptr);
+    return remote;
+  }
+
+  // Registers a recursive-prefetch token on the current `BindContext` bound to
+  // the given origin, and points the context's cross-origin factory at
+  // `cross_origin_factory_` so forwarded requests can be observed.
+  base::UnguessableToken RegisterRecursivePrefetchToken(
+      const url::Origin& origin) {
+    CHECK(bind_context_);
+    bind_context_->cross_origin_factory =
+        cross_origin_factory_.GetSafeWeakWrapper();
+    base::UnguessableToken token = base::UnguessableToken::Create();
+    bind_context_->total_tokens_generated++;
+    bind_context_->prefetch_isolation_infos.Put(
+        token,
+        net::IsolationInfo::Create(net::IsolationInfo::RequestType::kOther,
+                                   origin, origin, net::SiteForCookies()));
+    return token;
+  }
+
+  network::TestURLLoaderFactory proxied_factory_;
+  network::TestURLLoaderFactory cross_origin_factory_;
+  base::WeakPtr<SubresourceProxyingURLLoaderService::BindContext> bind_context_;
+};
+
+// Verifies that a recursive prefetch whose request URL is cross-origin with
+// the initiating document is forwarded via the cross-origin factory using the
+// `IsolationInfo` associated with its `recursive_prefetch_token`.
+IN_PROC_BROWSER_TEST_F(PrefetchRecursiveBrowserTest,
+                       RecursivePrefetchAllowsCrossOriginTarget) {
+  base::HistogramTester histogram_tester;
+  const char* prefetch_path = "/prefetch.html";
+  RegisterResponse(prefetch_path, ResponseEntry("<body></body>"));
+  RegisterRequestHandler(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(prefetch_path)));
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory =
+      BindFactoryForMainFrame();
+
+  const url::Origin token_origin =
+      url::Origin::Create(GURL("https://3p.example"));
+  base::UnguessableToken token = RegisterRecursivePrefetchToken(token_origin);
+
+  const GURL target_url("https://3p.example/subresource.js");
+  network::ResourceRequest request;
+  request.url = target_url;
+  request.load_flags = net::LOAD_PREFETCH;
+  request.request_initiator = shell()
+                                  ->web_contents()
+                                  ->GetPrimaryMainFrame()
+                                  ->GetLastCommittedOrigin();
+  request.recursive_prefetch_token = token;
+
+  network::TestURLLoaderClient client;
+  mojo::Remote<network::mojom::URLLoader> loader;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/0,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+  factory.FlushForTesting();
+
+  ASSERT_EQ(1, cross_origin_factory_.NumPending());
+  const network::ResourceRequest& forwarded =
+      cross_origin_factory_.GetPendingRequest(0)->request;
+  EXPECT_EQ(target_url, forwarded.url);
+  ASSERT_TRUE(forwarded.trusted_params);
+  EXPECT_EQ(token_origin,
+            forwarded.trusted_params->isolation_info.top_frame_origin());
+  EXPECT_EQ(token_origin,
+            forwarded.trusted_params->isolation_info.frame_origin());
+
+  histogram_tester.ExpectUniqueSample(
+      "Prefetch.RecursivePrefetch.TokenLookupResult",
+      RecursivePrefetchTokenLookupResult::kSuccess, 1);
+}
+
+// Verifies that a recursive prefetch whose request URL is same-origin with the
+// initiating document is rejected as a bad message and not forwarded to the
+// network.
+IN_PROC_BROWSER_TEST_F(PrefetchRecursiveBrowserTest,
+                       RecursivePrefetchRejectsInitiatorOriginTarget) {
+  base::HistogramTester histogram_tester;
+  const char* prefetch_path = "/prefetch.html";
+  RegisterResponse(prefetch_path, ResponseEntry("<body></body>"));
+  RegisterRequestHandler(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(prefetch_path)));
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory =
+      BindFactoryForMainFrame();
+
+  const url::Origin token_origin =
+      url::Origin::Create(GURL("https://3p.example"));
+  base::UnguessableToken token = RegisterRecursivePrefetchToken(token_origin);
+
+  // Use a target URL that matches the initiating document's origin.
+  const GURL same_origin_target_url =
+      embedded_test_server()->GetURL("/subresource.js");
+  network::ResourceRequest request;
+  request.url = same_origin_target_url;
+  request.load_flags = net::LOAD_PREFETCH;
+  request.request_initiator = shell()
+                                  ->web_contents()
+                                  ->GetPrimaryMainFrame()
+                                  ->GetLastCommittedOrigin();
+  request.recursive_prefetch_token = token;
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  network::TestURLLoaderClient client;
+  mojo::Remote<network::mojom::URLLoader> loader;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/0,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  client.RunUntilComplete();
+  EXPECT_EQ(net::ERR_INVALID_ARGUMENT, client.completion_status().error_code);
+  EXPECT_EQ("Prefetch/CreatePrefetchLoaderAndStart: initiator origin",
+            bad_message_observer.WaitForBadMessage());
+  EXPECT_EQ(0, cross_origin_factory_.NumPending());
+
+  histogram_tester.ExpectUniqueSample(
+      "Prefetch.RecursivePrefetch.TokenLookupResult",
+      RecursivePrefetchTokenLookupResult::kRejectedSameOrigin, 1);
+}
+
+// Verifies that presenting an invalid or evicted recursive prefetch token fails
+// gracefully with ERR_INVALID_ARGUMENT and logs kTokenNotFound without crashing
+// or reporting a bad message.
+IN_PROC_BROWSER_TEST_F(PrefetchRecursiveBrowserTest,
+                       RecursivePrefetchRejectsInvalidOrEvictedToken) {
+  base::HistogramTester histogram_tester;
+  const char* prefetch_path = "/prefetch.html";
+  RegisterResponse(prefetch_path, ResponseEntry("<body></body>"));
+  RegisterRequestHandler(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(prefetch_path)));
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory =
+      BindFactoryForMainFrame();
+  bind_context_->cross_origin_factory =
+      cross_origin_factory_.GetSafeWeakWrapper();
+
+  const GURL target_url("https://3p.example/subresource.js");
+  network::ResourceRequest request;
+  request.url = target_url;
+  request.load_flags = net::LOAD_PREFETCH;
+  request.request_initiator = shell()
+                                  ->web_contents()
+                                  ->GetPrimaryMainFrame()
+                                  ->GetLastCommittedOrigin();
+  request.recursive_prefetch_token = base::UnguessableToken::Create();
+
+  network::TestURLLoaderClient client;
+  mojo::Remote<network::mojom::URLLoader> loader;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/0,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
+  client.RunUntilComplete();
+  EXPECT_EQ(net::ERR_INVALID_ARGUMENT, client.completion_status().error_code);
+  EXPECT_EQ(0, cross_origin_factory_.NumPending());
+
+  histogram_tester.ExpectUniqueSample(
+      "Prefetch.RecursivePrefetch.TokenLookupResult",
+      RecursivePrefetchTokenLookupResult::kTokenNotFound, 1);
+}
+
+// Verifies that stored isolation infos for recursive prefetch tokens are managed
+// by an LRU cache, evicting older tokens when the limit is exceeded.
+IN_PROC_BROWSER_TEST_F(PrefetchRecursiveBrowserTest,
+                       RecursivePrefetchTokenLRUEviction) {
+  const char* prefetch_path = "/prefetch.html";
+  RegisterResponse(prefetch_path, ResponseEntry("<body></body>"));
+  RegisterRequestHandler(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(prefetch_path)));
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory =
+      BindFactoryForMainFrame();
+  ASSERT_TRUE(bind_context_);
+
+  std::vector<base::UnguessableToken> tokens;
+  constexpr size_t kNumTokens =
+      SubresourceProxyingURLLoaderService::BindContext::
+          kMaxPrefetchIsolationInfoEntries +
+      5;
+  for (size_t i = 0; i < kNumTokens; ++i) {
+    tokens.push_back(RegisterRecursivePrefetchToken(
+        url::Origin::Create(GURL(base::StringPrintf("https://%zu.example", i)))));
+  }
+
+  EXPECT_EQ(
+      SubresourceProxyingURLLoaderService::BindContext::
+          kMaxPrefetchIsolationInfoEntries,
+      bind_context_->prefetch_isolation_infos.size());
+
+  // The earliest 5 tokens should have been evicted.
+  for (size_t i = 0; i < 5; ++i) {
+    EXPECT_EQ(bind_context_->prefetch_isolation_infos.end(),
+              bind_context_->prefetch_isolation_infos.Peek(tokens[i]));
+  }
+
+  // The latest tokens should still be present.
+  for (size_t i = 5; i < kNumTokens; ++i) {
+    EXPECT_NE(bind_context_->prefetch_isolation_infos.end(),
+              bind_context_->prefetch_isolation_infos.Peek(tokens[i]));
+  }
+}
+
+// Verifies that a prefetch carrying both LOAD_RESTRICTED_PREFETCH_FOR_MAIN_FRAME
+// and a recursive_prefetch_token is rejected with a bad message.
+IN_PROC_BROWSER_TEST_F(PrefetchRecursiveBrowserTest,
+                       RejectsRestrictedMainFrameWithRecursiveToken) {
+  const char* prefetch_path = "/prefetch.html";
+  RegisterResponse(prefetch_path, ResponseEntry("<body></body>"));
+  RegisterRequestHandler(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(prefetch_path)));
+
+  mojo::Remote<network::mojom::URLLoaderFactory> factory =
+      BindFactoryForMainFrame();
+
+  const url::Origin token_origin =
+      url::Origin::Create(GURL("https://3p.example"));
+  base::UnguessableToken token = RegisterRecursivePrefetchToken(token_origin);
+
+  network::ResourceRequest request;
+  request.url = GURL("https://3p.example/target.html");
+  request.load_flags =
+      net::LOAD_PREFETCH | net::LOAD_RESTRICTED_PREFETCH_FOR_MAIN_FRAME;
+  request.request_initiator = shell()
+                                  ->web_contents()
+                                  ->GetPrimaryMainFrame()
+                                  ->GetLastCommittedOrigin();
+  request.recursive_prefetch_token = token;
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  network::TestURLLoaderClient client;
+  mojo::Remote<network::mojom::URLLoader> loader;
+  factory->CreateLoaderAndStart(
+      loader.BindNewPipeAndPassReceiver(), /*request_id=*/0,
+      network::mojom::kURLLoadOptionNone, request, client.CreateRemote(),
+      net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS));
+
... (truncated)
Loading diff…

Original Bug Report

reported by er...@chromium.org

Site Isolation bypass and browser OOM via recursive_prefetch_token logic flaw

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 without the security team.

Overview: A logic flaw in Chrome’s prefetch URL loader allows a compromised renderer to use a recursive_prefetch_token to bypass network partitioning. By attaching a token generated for one origin to a request destined for another, an attacker can access partitioned cookies or cache data. Furthermore, the unbounded storage of these tokens in the browser process enables a potential memory exhaustion DoS.

Affected files:

  • content/browser/loader/prefetch_url_loader_service_context.cc
  • content/browser/loader/prefetch_url_loader.cc

Estimated timestamp from git blame: 2024-08-21

Summary

A vulnerability in Chrome’s recursive prefetch implementation allows a compromised renderer process to bypass Site Isolation network partitioning. By generating a recursive_prefetch_token for a victim origin and then reusing that token on a request to an arbitrary, attacker-controlled origin, an attacker can force the browser to use the IsolationInfo of the victim origin. This enables cross-site data access, such as reading partitioned cookies (CHIPS). Additionally, the tokens are stored indefinitely without bounds checking, allowing a compromised renderer to trigger a browser-process Out-Of-Memory (OOM) crash.

Technical Details

1. Site Isolation Bypass (Origin Confusion)

When the browser handles a restricted cross-origin prefetch, it generates a recursive_prefetch_token in PrefetchURLLoaderServiceContext::GenerateRecursivePrefetchToken. This token, paired with an IsolationInfo locked to the prefetch’s destination origin, is stored in the prefetch_isolation_infos map within the browser process, and the token is returned to the renderer.

When a compromised renderer sends a new prefetch request containing this recursive_prefetch_token, the browser retrieves the IsolationInfo and applies it to the new request in PrefetchURLLoaderServiceContext::CreatePrefetchLoaderAndStart:

// content/browser/loader/prefetch_url_loader_service_context.cc:114
} else if (resource_request.recursive_prefetch_token) {
  // ...
  auto isolation_info_iterator =
      current_context.prefetch_isolation_infos.find(
          resource_request.recursive_prefetch_token.value());
  // ...
  resource_request.trusted_params = network::ResourceRequest::TrustedParams();
  resource_request.trusted_params->isolation_info =
      isolation_info_iterator->second;
  network_loader_factory_to_use = current_context.cross_origin_factory;
}

The vulnerability exists because the browser blindly assigns the resurrected IsolationInfo without verifying that the new resource_request.url matches the origins stored within that IsolationInfo. A compromised renderer can obtain a token for victim.com and attach it to a prefetch destined for attacker.com. The network service will then dispatch the request to attacker.com using the IsolationInfo and network partition of victim.com, attaching any of the attacker’s partitioned cookies that are stored under the victim’s context.

2. Browser Process OOM (Denial of Service)

The prefetch_isolation_infos map is defined as std::map<base::UnguessableToken, net::IsolationInfo> inside SubresourceProxyingURLLoaderService::BindContext.

Every time a restricted prefetch triggers GenerateRecursivePrefetchToken, a new entry is blindly inserted into this map (content/browser/loader/prefetch_url_loader_service_context.cc:287). There is no maximum size limit, TTL, or eviction logic for these entries. A compromised renderer can continuously initiate restricted prefetches to unique URLs, causing the map to grow indefinitely until the browser process exhausts available memory.

Potential Steps to Reproduce

Note: These are suggested steps based on source code analysis; a full Proof-of-Concept has not been run.

  1. An attacker compromises a sandboxed renderer process (e.g., via a V8 exploit) on https://attacker.com.
  2. The compromised renderer crafts a network::ResourceRequest to https://victim.com/target_resource with load_flags set to net::LOAD_RESTRICTED_PREFETCH_FOR_MAIN_FRAME | net::LOAD_PREFETCH.
  3. The renderer sends this via the URLLoaderFactory Mojo interface. The browser processes it and returns an UnguessableToken (recursive_prefetch_token) bound to victim.com’s IsolationInfo.
  4. The renderer extracts the token and crafts a second prefetch request, this time destined for https://attacker.com/steal_cookies.
  5. The renderer attaches the extracted recursive_prefetch_token to this second request.
  6. The browser receives the request, takes the else if (resource_request.recursive_prefetch_token) branch, and assigns victim.com’s IsolationInfo to the request to attacker.com.
  7. The Network Service routes the request to attacker.com using victim.com’s network partition, exposing partitioned cookies (CHIPS).

Suggested Fix

  1. Enforce Origin Matching: In PrefetchURLLoaderServiceContext::CreatePrefetchLoaderAndStart, when handling a recursive_prefetch_token, explicitly verify that url::Origin::Create(resource_request.url) matches the expected origin(s) stored within the resurrected IsolationInfo.
  2. Mitigate OOM: Implement a reasonable size cap (e.g., LRU cache) and/or an expiration mechanism (TTL) for the prefetch_isolation_infos map to prevent unbounded growth.

Evaluated with Chrome root at commit: a79fb35f774128179732608bb342f988ebe71378


Results 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