CVE-2026-87476
Overview
Background
- Stale-while-revalidate (SWR)
- an HTTP caching policy where a stale cached resource is served immediately while a low-priority follow-up request revalidates it in the background.
- `world_for_csp` / `DOMWrapperWorld`
- the JavaScript world (main world vs. an extension/isolated world) associated with a request, used downstream for CSP world selection and Resource Timing filtering.
- `ResourceLoaderOptions`
- the options bundle attached to a fetch that carries
world_for_csp, among other fields; constructing it with a null world discards the originating world. - `ResourceFetcher::RevalidateStaleResource`
- the method that builds and issues the SWR background revalidation request from a stale
Resource.
Root Cause Analysis
ResourceFetcher::RevalidateStaleResource built the SWR follow-up request’s ResourceLoaderOptions with a hard-coded ResourceLoaderOptions(/*world=*/nullptr), discarding the DOMWrapperWorld that issued the original request (the removed TODO even flagged this as unresolved). As a result the background revalidation was treated as belonging to the null/main world rather than to the isolated world that originated it, so downstream consumers — CSP world selection and Resource Timing filtering — applied the wrong world’s policy to the follow-up.
The fix passes stale_resource->Options().world_for_csp.Get() into the ResourceLoaderOptions constructor so the follow-up carries the same world as its originating request. This restores the invariant that a stale-revalidation request is authorized and filtered consistently with the request that spawned it; the added test confirms the follow-up observed via ResourceLoadObserver::WillSendRequest retains the isolated world_for_csp.
world when constructing the SWR follow-up’s ResourceLoaderOptions, dropping the originating isolated world; the fix propagates the stale resource’s world_for_csp so CSP and Resource Timing decisions match the original request.Attack Path
- Issue a fetch from an isolated world
Content running in an isolated world (e.g. an extension content script) requests a resource served with
Cache-Control: stale-while-revalidate. - Resource goes stale
The cached entry becomes stale, so a later fetch triggers
RevalidateStaleResourceto launch a background follow-up request. - World is dropped
The pre-patch code constructs the follow-up’s
ResourceLoaderOptionswith a nullworld, so the revalidation is no longer associated with the isolated world that originated it. - Wrong-world handling
Downstream CSP world selection and Resource Timing filtering treat the follow-up as if it came from a different (null/main) world, causing incorrect authorization/visibility for the request (any resulting cross-world information exposure is
(inferred)).
Impact Assessment
world_for_csp on the follow-up request, while any downstream policy bypass or cross-world data exposure is (inferred). The precondition is a resource fetched from an isolated world under a stale-while-revalidate cache policy that later triggers revalidation.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pthird_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.cc |
modified |
Files Changed
third_party/blink/renderer/platform/loader/fetch/resource_fetcher_test.ccthird_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
Audit Directions
- Hard-coded null world/options on derived requestsAudit places that construct
ResourceLoaderOptions(or similar option bundles) for follow-up/derived requests with a literalnullptrworld instead of copying the originating request’s context. - Context propagation across background/async requestsReview revalidation, prefetch, and retry paths to confirm they carry forward the originating world/origin/CSP context rather than defaulting it.
- Resolved-looking TODOs on security-relevant fieldsSearch loader code for
TODOs that question whether a null/default value is correct for authorization-relevant fields likeworld_for_csp.
Patch
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();
Regression Test / PoC
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();
Original Bug Report
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.
- Install an extension with a content script that executes on an untrusted page (e.g.,
attacker.example). - The content script makes a fetch request to its backend:
fetch('https://ext-api.example/data?secret=TOKEN'). - The backend responds with headers enabling SWR:
Cache-Control: max-age=1, stale-while-revalidate=3600. - The untrusted page waits for the cache to become stale (e.g., > 1 second).
- 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.
- The untrusted page polls
performance.getEntriesByType('resource'). - The page observes the SWR background request in the returned entries and extracts the
secret=TOKENfrom 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.