Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Loader
DescriptionIncorrect authorization in Loader
ComponentLoader
Bug ClassLogic Error
Tracker506390077
Fix commit0c9003632d87 (chromium/src) +89/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
TEST_P
third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
modified

Files Changed

  • third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
  • third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
From 0c9003632d8701de4acd6bf519c78d01f61db494 Mon Sep 17 00:00:00 2001
From: Minoru Chikamune <chikamune@chromium.org>
Date: Thu, 06 Aug 2026 00:01:03 -0700
Subject: [PATCH] Propagate originating world to stale-while-revalidate follow-up requests

ResourceFetcher::RevalidateStaleResource was constructing the SWR
follow-up's ResourceLoaderOptions with a hard-coded null world rather
than the world that issued the original request. Carry the stale
resource's world_for_csp through so downstream consumers (Resource
Timing filtering, CSP world selection) treat the follow-up consistently
with its originating request, and remove the now-resolved TODO.

Add a unit test that drives a MockResource through the SWR path from an
isolated world and asserts the follow-up observed via
ResourceLoadObserver::WillSendRequest retains the same world_for_csp.

TAG=agy
CONV=2da625aa-002d-4fbc-a977-3436070e4b3f

Fixed: 506390077
Change-Id: Ie12c2e39548e2a21a9f8d112b8b567d232f83df3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8191659
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Minoru Chikamune <chikamune@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1674759}
---

diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
index d83be5b5..1882ce5 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
@@ -3227,10 +3227,9 @@
   // requests.
   ResourceRequest request;
   request.CopyHeadFrom(stale_resource->GetResourceRequest());
-  // TODO(https://crbug.com/1405800): investigate whether it's correct to use a
-  // null `world` in the ResourceLoaderOptions below.
-  FetchParameters params(std::move(request),
-                         ResourceLoaderOptions(/*world=*/nullptr));
+  FetchParameters params(
+      std::move(request),
+      ResourceLoaderOptions(stale_resource->Options().world_for_csp.Get()));
   params.SetStaleRevalidation(true);
   params.MutableResourceRequest().SetSkipServiceWorker(true);
   // Stale revalidation resource requests should be very low regardless of
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
index 35ff0ee..aff18f6 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
@@ -157,10 +157,11 @@
     void WillSendRequest(const ResourceRequest& request,
                          const ResourceResponse& redirect_response,
                          ResourceType,
-                         const ResourceLoaderOptions&,
+                         const ResourceLoaderOptions& options,
                          RenderBlockingBehavior,
                          const Resource*) override {
       request_ = PartialResourceRequest(request);
+      world_for_csp_ = options.world_for_csp;
     }
     void DidChangePriority(uint64_t identifier,
                            ResourceLoadPriority,
@@ -196,12 +197,24 @@
     const std::optional<PartialResourceRequest>& GetLastRequest() const {
       return request_;
     }
+    const DOMWrapperWorld* GetLastWorldForCsp() const {
+      return world_for_csp_.Get();
+    }
 
-    void ClearLastRequest() { request_ = std::nullopt; }
+    void ClearLastRequest() {
+      request_ = std::nullopt;
+      world_for_csp_ = nullptr;
+    }
+
+    void Trace(Visitor* visitor) const override {
+      visitor->Trace(world_for_csp_);
+      ResourceLoadObserver::Trace(visitor);
+    }
 
    private:
     std::optional<PartialResourceRequest> request_;
     bool interested_in_all_requests_ = false;
+    Member<const DOMWrapperWorld> world_for_csp_;
   };
 
  protected:
@@ -1197,6 +1210,77 @@
   EXPECT_FALSE(MemoryCache::Get()->Contains(resource));
 }
 
+TEST_P(ResourceFetcherTest, StaleWhileRevalidatePropagatesIsolatedWorld) {
+  scoped_refptr<const SecurityOrigin> source_origin =
+      SecurityOrigin::CreateUniqueOpaque();
+  auto* observer = MakeGarbageCollected<TestResourceLoadObserver>();
+  MockFetchContext* context = MakeGarbageCollected<MockFetchContext>();
+  auto* fetcher = CreateFetcher(
+      *MakeGarbageCollected<TestResourceFetcherProperties>(source_origin),
+      context);
+  fetcher->SetResourceLoadObserver(observer);
+
+  KURL url("http://127.0.0.1:8000/foo.html");
+  FetchParameters fetch_params =
+      FetchParameters::CreateForTest(ResourceRequest(url));
+  DOMWrapperWorld* isolated_world = DOMWrapperWorld::EnsureIsolatedWorld(
+      /*v8::Isolate=*/nullptr, blink::kIsolatedWorldIdLimit - 1);
+  fetch_params.MutableOptions().world_for_csp = isolated_world;
+
+  ResourceResponse response(url);
+  response.SetHttpStatusCode(200);
+  response.SetHttpHeaderField(
+      http_names::kCacheControl,
+      AtomicString("max-age=0, stale-while-revalidate=40"));
+
+  platform_->GetURLLoaderMockFactory()->RegisterURL(
+      url, WrappedResourceResponse(response),
+      test::PlatformTestDataPath(kTestResourceFilename));
+  Resource* resource = MockResource::Fetch(fetch_params, fetcher, nullptr);
+  ASSERT_TRUE(resource);
+
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_TRUE(resource->IsLoaded());
+  EXPECT_TRUE(MemoryCache::Get()->Contains(resource));
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+
+  ResourceRequest resource_request(url);
+  resource_request.SetRequestContext(
+      mojom::blink::RequestContextType::INTERNAL);
+  FetchParameters fetch_params2 =
+      FetchParameters::CreateForTest(std::move(resource_request));
+  fetch_params2.MutableOptions().world_for_csp = isolated_world;
+  Resource* new_resource = MockResource::Fetch(fetch_params2, fetcher, nullptr);
+  EXPECT_EQ(resource, new_resource);
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_TRUE(resource->IsLoaded());
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+
+  // Advance the clock, make sure the original resource gets removed from the
+  // memory cache after the revalidation completes.
+  task_environment_.AdvanceClock(base::Seconds(1));
+  ResourceResponse revalidate_response(url);
+  revalidate_response.SetHttpStatusCode(200);
+  platform_->GetURLLoaderMockFactory()->UnregisterURL(url);
+  platform_->GetURLLoaderMockFactory()->RegisterURL(
+      url, WrappedResourceResponse(revalidate_response),
+      test::PlatformTestDataPath(kTestResourceFilename));
+  new_resource = MockResource::Fetch(fetch_params2, fetcher, nullptr);
+  EXPECT_EQ(resource, new_resource);
+  EXPECT_TRUE(MemoryCache::Get()->Contains(resource));
+  observer->ClearLastRequest();
+
+  static_cast<scheduler::FakeTaskRunner*>(fetcher->GetTaskRunner().get())
+      ->AdvanceTimeAndRun(base::Seconds(0));
+  std::optional<PartialResourceRequest> swr_request =
+      observer->GetLastRequest();
+  ASSERT_TRUE(swr_request.has_value());
+  EXPECT_EQ(ResourceLoadPriority::kVeryLow, swr_request->Priority());
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_FALSE(MemoryCache::Get()->Contains(resource));
+}
+
 TEST_P(ResourceFetcherTest, CachedResourceShouldNotCrashByNullURL) {
   auto* fetcher = CreateFetcher();
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
index 35ff0ee..aff18f6 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc
@@ -157,10 +157,11 @@
     void WillSendRequest(const ResourceRequest& request,
                          const ResourceResponse& redirect_response,
                          ResourceType,
-                         const ResourceLoaderOptions&,
+                         const ResourceLoaderOptions& options,
                          RenderBlockingBehavior,
                          const Resource*) override {
       request_ = PartialResourceRequest(request);
+      world_for_csp_ = options.world_for_csp;
     }
     void DidChangePriority(uint64_t identifier,
                            ResourceLoadPriority,
@@ -196,12 +197,24 @@
     const std::optional<PartialResourceRequest>& GetLastRequest() const {
       return request_;
     }
+    const DOMWrapperWorld* GetLastWorldForCsp() const {
+      return world_for_csp_.Get();
+    }
 
-    void ClearLastRequest() { request_ = std::nullopt; }
+    void ClearLastRequest() {
+      request_ = std::nullopt;
+      world_for_csp_ = nullptr;
+    }
+
+    void Trace(Visitor* visitor) const override {
+      visitor->Trace(world_for_csp_);
+      ResourceLoadObserver::Trace(visitor);
+    }
 
    private:
     std::optional<PartialResourceRequest> request_;
     bool interested_in_all_requests_ = false;
+    Member<const DOMWrapperWorld> world_for_csp_;
   };
 
  protected:
@@ -1197,6 +1210,77 @@
   EXPECT_FALSE(MemoryCache::Get()->Contains(resource));
 }
 
+TEST_P(ResourceFetcherTest, StaleWhileRevalidatePropagatesIsolatedWorld) {
+  scoped_refptr<const SecurityOrigin> source_origin =
+      SecurityOrigin::CreateUniqueOpaque();
+  auto* observer = MakeGarbageCollected<TestResourceLoadObserver>();
+  MockFetchContext* context = MakeGarbageCollected<MockFetchContext>();
+  auto* fetcher = CreateFetcher(
+      *MakeGarbageCollected<TestResourceFetcherProperties>(source_origin),
+      context);
+  fetcher->SetResourceLoadObserver(observer);
+
+  KURL url("http://127.0.0.1:8000/foo.html");
+  FetchParameters fetch_params =
+      FetchParameters::CreateForTest(ResourceRequest(url));
+  DOMWrapperWorld* isolated_world = DOMWrapperWorld::EnsureIsolatedWorld(
+      /*v8::Isolate=*/nullptr, blink::kIsolatedWorldIdLimit - 1);
+  fetch_params.MutableOptions().world_for_csp = isolated_world;
+
+  ResourceResponse response(url);
+  response.SetHttpStatusCode(200);
+  response.SetHttpHeaderField(
+      http_names::kCacheControl,
+      AtomicString("max-age=0, stale-while-revalidate=40"));
+
+  platform_->GetURLLoaderMockFactory()->RegisterURL(
+      url, WrappedResourceResponse(response),
+      test::PlatformTestDataPath(kTestResourceFilename));
+  Resource* resource = MockResource::Fetch(fetch_params, fetcher, nullptr);
+  ASSERT_TRUE(resource);
+
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_TRUE(resource->IsLoaded());
+  EXPECT_TRUE(MemoryCache::Get()->Contains(resource));
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+
+  ResourceRequest resource_request(url);
+  resource_request.SetRequestContext(
+      mojom::blink::RequestContextType::INTERNAL);
+  FetchParameters fetch_params2 =
+      FetchParameters::CreateForTest(std::move(resource_request));
+  fetch_params2.MutableOptions().world_for_csp = isolated_world;
+  Resource* new_resource = MockResource::Fetch(fetch_params2, fetcher, nullptr);
+  EXPECT_EQ(resource, new_resource);
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_TRUE(resource->IsLoaded());
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+
+  // Advance the clock, make sure the original resource gets removed from the
+  // memory cache after the revalidation completes.
+  task_environment_.AdvanceClock(base::Seconds(1));
+  ResourceResponse revalidate_response(url);
+  revalidate_response.SetHttpStatusCode(200);
+  platform_->GetURLLoaderMockFactory()->UnregisterURL(url);
+  platform_->GetURLLoaderMockFactory()->RegisterURL(
+      url, WrappedResourceResponse(revalidate_response),
+      test::PlatformTestDataPath(kTestResourceFilename));
+  new_resource = MockResource::Fetch(fetch_params2, fetcher, nullptr);
+  EXPECT_EQ(resource, new_resource);
+  EXPECT_TRUE(MemoryCache::Get()->Contains(resource));
+  observer->ClearLastRequest();
+
+  static_cast<scheduler::FakeTaskRunner*>(fetcher->GetTaskRunner().get())
+      ->AdvanceTimeAndRun(base::Seconds(0));
+  std::optional<PartialResourceRequest> swr_request =
+      observer->GetLastRequest();
+  ASSERT_TRUE(swr_request.has_value());
+  EXPECT_EQ(ResourceLoadPriority::kVeryLow, swr_request->Priority());
+  EXPECT_EQ(isolated_world, observer->GetLastWorldForCsp());
+  platform_->GetURLLoaderMockFactory()->ServeAsynchronousRequests();
+  EXPECT_FALSE(MemoryCache::Get()->Contains(resource));
+}
+
 TEST_P(ResourceFetcherTest, CachedResourceShouldNotCrashByNullURL) {
   auto* fetcher = CreateFetcher();
Loading diff…

Original Bug Report

reported by vm...@google.com

Information leak of extension fetch URLs via SWR Resource Timing

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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Blink’s stale-while-revalidate (SWR) mechanism creates background revalidation requests using a null DOMWrapperWorld context. This strips the isolated world information required to hide extension-initiated fetches from the page’s Performance Timeline. Consequently, web pages can potentially observe sensitive extension URLs and query parameters using the Resource Timing API.

Affected files:

  • third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc

Estimated timestamp from git blame: 2023-01-09

Summary

A logic flaw exists in how Blink handles stale-while-revalidate (SWR) network fetches. Background SWR requests lose their originating DOMWrapperWorld context, causing extension-initiated fetches to bypass privacy checks and be incorrectly logged to the untrusted page’s PerformanceResourceTiming buffer.

Root Cause Analysis

When a cached resource is stale but eligible for stale-while-revalidate, Blink schedules a background revalidation request via ResourceFetcher::RevalidateStaleResource (third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc).

During the creation of this background request, the code explicitly drops the world context:

void ResourceFetcher::RevalidateStaleResource(Resource* stale_resource) {
  // ...
  ResourceRequest request;
  request.CopyHeadFrom(stale_resource->GetResourceRequest());
  // TODO(https://crbug.com/1405800): investigate whether it's correct to use a
  // null `world` in the ResourceLoaderOptions below.
  FetchParameters params(std::move(request),
                         ResourceLoaderOptions(/*world=*/nullptr));
  // ...
}

When this background request finishes, Blink attempts to record its timing information via ResourceFetcher::PopulateAndAddResourceTimingInfo. To prevent web pages from spying on extension activity, this function checks if the fetch originated from an isolated world:

  // Resource timing entries that correspond to resources fetched by extensions
  // are precluded.
  if (resource->Options().world_for_csp &&
      resource->Options().world_for_csp->IsIsolatedWorld()) {
    return;
  }

Because the world_for_csp was hardcoded to nullptr during SWR request creation, this security check evaluates to false. The function proceeds to expose the timing entry (including the full request URL with any sensitive query parameters) to the main world’s performance timeline.

Potential Steps to Reproduce

Note: Our tooling agent does not currently have the ability to execute code, so these are suggested steps based on static analysis.

  1. Install an extension with a content script that executes on an untrusted page (e.g., attacker.example).
  2. The content script makes a fetch request to its backend: fetch('https://ext-api.example/data?secret=TOKEN').
  3. The backend responds with headers enabling SWR: Cache-Control: max-age=1, stale-while-revalidate=3600.
  4. The untrusted page waits for the cache to become stale (e.g., > 1 second).
  5. The extension performs the exact same fetch again (e.g., triggered by a timer or user interaction). Blink serves the stale cache and triggers a background SWR fetch.
  6. The untrusted page polls performance.getEntriesByType('resource').
  7. The page observes the SWR background request in the returned entries and extracts the secret=TOKEN from the URL, achieving a cross-world information leak.

Suggested Fix

Update ResourceFetcher::RevalidateStaleResource to inherit the DOMWrapperWorld from the original stale_resource instead of passing nullptr.

  FetchParameters params(std::move(request),
                         ResourceLoaderOptions(stale_resource->Options().world_for_csp.Get()));

Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f


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
Links in the report