CVE-2026-76027
Overview
Background
- CacheStorage
- The Service Worker Cache API backing store (
content/browser/cache_storage/).cache.put(request, response)persists aFetchAPIResponseinto a disk_cache entry, serialized viaproto::CacheResponse;cache.match()/CreateResponsereconstructs aFetchAPIResponsefrom that proto. - `has_range_requested`
- A bit on
FetchAPIResponserecording that the response was produced for a range request.ResourceLoader::DidReceiveResponseInternaluses it to decide whether to run the security check that rejects opaque partial (206) / 416 responses when the current request is not a range request. - Opaque 206 / 416
- Cross-origin no-cors range responses. The Fetch spec forbids replaying an opaque range response to a non-range context, because doing so can leak cross-origin byte ranges / be used as a confused-deputy against the content.
- `proto::CacheResponse`
- The persisted schema; new field
19 has_range_requestedis added by this patch. - `CacheStorageCache::CreateResponse` / `PutDidCreateEntry`
- Deserialize and serialize paths respectively; the two functions where the bit was mishandled.
Root Cause Analysis
When persisting a cached response, PutDidCreateEntry serialized many response fields (request_include_credentials, timing_allow_passed, …) but never wrote has_range_requested into proto::CacheResponse. On read-back, CreateResponse tried to recompute the bit with headers.contains(net::HttpRequestHeaders::kRange) — testing for a Range request header on the stored response headers, which is never present. The two mistakes compound: the true value was dropped on write, and the recompute on read always produced false. Consequently every response served from CacheStorage came back with has_range_requested == false.
That reset defeats the downstream guard in ResourceLoader::DidReceiveResponseInternal: with has_range_requested false, the loader skips the check that rejects opaque 206/416 responses for non-range requests. An attacker could store an opaque cross-origin 206 Partial Content (or 416) response in CacheStorage via a Service Worker and later have the SW return it for an ordinary non-range request (image/script/fetch), bypassing the spec-mandated rejection and surfacing cross-origin partial content where it should be blocked. The patch persists the real bit (response_metadata->set_has_range_requested(...)) and, for backward compatibility with entries written before field 19 existed, makes CreateResponse force has_range_requested = true for any opaque response whose status is HTTP_PARTIAL_CONTENT or HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, so such responses can never be replayed to a non-range request. New WPTs (sw.https.window.js, sw-416.https.window.js) confirm a cached opaque ranged response now rejects.
The advisory calls this “Memory corruption in CacheStorage,” but the diff shows no memory-safety sink — it is a dropped/incorrectly-recomputed security flag that bypasses a cross-origin range-response restriction. The substantiated class is a cross-origin policy bypass / information-disclosure, not memory corruption.
has_range_requested) was never persisted and was “recomputed” by looking for a request-only header on a response, so it always read back false — silently disabling the check that stops opaque cross-origin range responses from being replayed to non-range requests.Attack Path
- Register a Service Worker Attacker page controls a SW on its origin (standard capability).
- Fetch a cross-origin opaque range response
The SW issues a no-cors range request to a victim/remote host that returns an opaque 206 (or a 416), e.g. via media/audio element range fetch (the added WPT does exactly this with
partial-script.py). - Store it in CacheStorage
cache.put(key, opaqueRangeResponse)persists the response;has_range_requestedis dropped to false in the proto. - Replay to a non-range context
Later the SW answers an ordinary
fetch(..., {mode:'no-cors'})/<img>/<script>load withcache.match(key); pre-patch the loader accepts it becausehas_range_requestedis false. - Bypass achieved A partial/opaque cross-origin response is delivered to a non-range request, defeating the Fetch opaque-range rejection (potential cross-origin content/side-channel exposure).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pcontent/browser/cache_storage/cache_storage_cache_unittest.cc |
modified |
Files Changed
content/browser/cache_storage/cache_storage_cache.cccontent/browser/cache_storage/cache_storage_cache_unittest.ccthird_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.jsthird_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.jscontent/browser/cache_storage/cache_storage.protothird_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js
Audit Directions
- Dropped-on-persist flagsAudit
PutDidCreateEntry/CreateResponse(and other proto (de)serializers in CacheStorage, Background Fetch, HTTP cache) for security-relevantFetchAPIResponsebits that are set in one direction but not the other. - Request-vs-response header confusionSearch for checks that inspect response headers for request-only headers (
Range,Authorization) or vice versa, which silently evaluate to a fixed value. - Opaque-range enforcement reachabilityReview every path feeding
ResourceLoader::DidReceiveResponseInternal’s opaque-range check (SW-synthesized responses, Background Fetch, navigation preload) to ensurehas_range_requestedis trustworthy end to end.
Patch
From edd9a6c0d0c168f442a480731e3abc22f0fb96c2 Mon Sep 17 00:00:00 2001 From: Eriko Kurimoto <[email protected]> Date: Thu, 22 Oct 2026 07:46:24 -0700 Subject: [PATCH] [CacheStorage] Persist has_range_requested on cached responses Previously, CacheStorageCache::PutDidCreateEntry did not persist FetchAPIResponse::has_range_requested in proto::CacheResponse, and CreateResponse checked whether the response headers contained a Range header (which is a request header and never present on responses). As a result, cached responses returned by CacheStorage always had has_range_requested reset to false. When an opaque 206 Partial Content or 416 Range Not Satisfiable response was stored in CacheStorage and later returned by a Service Worker, ResourceLoader::DidReceiveResponseInternal skipped the check that rejects opaque range responses for non-range requests. Persist has_range_requested in proto::CacheResponse, and ensure CreateResponse also treats opaque 206 and 416 responses as range-requested even if the stored proto field is unset or false. Bug: 568894475 Change-Id: I9c4d1f2a3e8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8618165 Commit-Queue: Eriko Kurimoto <[email protected]> Reviewed-by: Mingyu Lei <[email protected]> Cr-Commit-Position: refs/heads/main@{#1724757} --- diff --git a/content/browser/cache_storage/cache_storage.proto b/content/browser/cache_storage/cache_storage.proto index d449f0d..916f53f7 100644 --- a/content/browser/cache_storage/cache_storage.proto +++ b/content/browser/cache_storage/cache_storage.proto @@ -86,6 +86,7 @@ // than consuming this field directly. optional bool request_include_credentials = 17; optional bool timing_allow_passed = 18; + optional bool has_range_requested = 19; } message CacheMetadata { diff --git a/content/browser/cache_storage/cache_storage_cache.cc b/content/browser/cache_storage/cache_storage_cache.cc index a21c4d8..12d3c52 100644 --- a/content/browser/cache_storage/cache_storage_cache.cc +++ b/content/browser/cache_storage/cache_storage_cache.cc @@ -545,9 +545,16 @@ ? metadata.response().timing_allow_passed() : true; - // While we block most partial responses from being stored, we can have - // partial responses for bgfetch or opaque responses. - bool has_range_requested = headers.contains(net::HttpRequestHeaders::kRange); + // The stored `has_range_requested` bit is supplied by the renderer via + // `cache.put()`, and entries written before the proto field was added do not + // have it set. Treat opaque 206 and 416 responses as range-requested so + // they cannot be replayed to non-range requests. + bool has_range_requested = + metadata.response().has_range_requested() || + (response_type == network::mojom::FetchResponseType::kOpaque && + (metadata.response().status_code() == net::HTTP_PARTIAL_CONTENT || + metadata.response().status_code() == + net::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE)); return blink::mojom::FetchAPIResponse::New( url_list, metadata.response().status_code(), @@ -2043,6 +2050,8 @@ put_context->response->request_include_credentials); response_metadata->set_timing_allow_passed( put_context->response->timing_allow_passed); + response_metadata->set_has_range_requested( + put_context->response->has_range_requested); // Get a temporary copy of the entry pointer before passing it in base::Bind. disk_cache::Entry* temp_entry_ptr = put_context->cache_entry.get(); diff --git a/content/browser/cache_storage/cache_storage_cache_unittest.cc b/content/browser/cache_storage/cache_storage_cache_unittest.cc index 01d0356a..5ce683c 100644 --- a/content/browser/cache_storage/cache_storage_cache_unittest.cc +++ b/content/browser/cache_storage/cache_storage_cache_unittest.cc @@ -64,6 +64,7 @@ #include "net/base/url_util.h" #include "net/disk_cache/disk_cache.h" #include "net/http/http_connection_info.h" +#include "net/http/http_status_code.h" #include "storage/browser/blob/blob_storage_context.h" #include "storage/browser/quota/quota_manager_proxy.h" #include "storage/browser/test/blob_test_utils.h" @@ -3378,6 +3379,46 @@ EXPECT_TRUE(callback_response_->timing_allow_passed); } +TEST_P(CacheStorageCacheTestP, HasRangeRequested) { + EXPECT_TRUE(Keys()); + + // Put a response with `has_range_requested = false`. + blink::mojom::FetchAPIResponsePtr response = CreateNoBodyResponse(); + response->has_range_requested = false; + EXPECT_TRUE(Put(no_body_request_, std::move(response))); + + // Match the response and verify `has_range_requested` is preserved as + // `false`. + EXPECT_TRUE(Match(no_body_request_)); + ASSERT_TRUE(callback_response_); + EXPECT_FALSE(callback_response_->has_range_requested); + + // Put a response with `has_range_requested = true`. + blink::mojom::FetchAPIResponsePtr response2 = CreateBlobBodyResponse(); + response2->has_range_requested = true; + EXPECT_TRUE(Put(body_request_, std::move(response2))); + + // Match the response and verify `has_range_requested` is preserved as `true`. + EXPECT_TRUE(Match(body_request_)); + ASSERT_TRUE(callback_response_); + EXPECT_TRUE(callback_response_->has_range_requested); + + // Opaque 206 and 416 responses force `has_range_requested = true` even if + // stored with `has_range_requested = false`. + for (int status_code : + {net::HTTP_PARTIAL_CONTENT, net::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE}) { + blink::mojom::FetchAPIResponsePtr opaque_partial = CreateBlobBodyResponse(); + opaque_partial->response_type = network::mojom::FetchResponseType::kOpaque; + opaque_partial->status_code = status_code; + opaque_partial->has_range_requested = false; + EXPECT_TRUE(Put(body_request_, std::move(opaque_partial))); + + EXPECT_TRUE(Match(body_request_)); + ASSERT_TRUE(callback_response_); + EXPECT_TRUE(callback_response_->has_range_requested); + } +} + INSTANTIATE_TEST_SUITE_P(CacheStorageCacheTest, CacheStorageCacheTestP, ::testing::Values(false, true)); diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js b/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js index dfa537a2..2f25f72 100644 --- a/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js +++ b/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js @@ -31,6 +31,12 @@ case 'use-stored-ranged-response': useStoredRangeResponse(event); return; + case 'cache-ranged-response': + cacheRangedResponse(event); + return; + case 'use-cached-ranged-response': + useCachedRangeResponse(event); + return; case 'broadcast-accept-encoding': broadcastAcceptEncoding(event); return; @@ -148,6 +154,32 @@ }()); } +const RANGE_CACHE_NAME = 'range-response-cache'; +const RANGE_CACHE_KEY = 'cached-ranged-response'; + +function cacheRangedResponse(event) { + /** @type Request */ + const request = event.request; + const id = new URL(request.url).searchParams.get('id'); + + event.waitUntil((async () => { + const response = await fetch(event.request); + const cache = await caches.open(RANGE_CACHE_NAME); + await cache.put(RANGE_CACHE_KEY, response); + broadcast({ id }); + })()); + + // Just send back any response, it isn't important for the test. + event.respondWith(new Response('')); +} + +function useCachedRangeResponse(event) { + event.respondWith((async () => { + const cache = await caches.open(RANGE_CACHE_NAME); + return cache.match(RANGE_CACHE_KEY); + })()); +} + function broadcastAcceptEncoding(event) { /** @type Request */ const request = event.request; diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js b/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js index ca4436a..30d20628 100644 --- a/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js +++ b/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js @@ -59,3 +59,30 @@ const loadImagePromise = loadImage('?action=use-stored-ranged-response', { doc: w.document }); await promise_rejects_js(t, Error, loadImagePromise); }, `416 response not allowed following no-cors ranged request`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const cachedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('range-not-satisfiable', '1'); + url.searchParams.set('type', 'image/png'); + url.searchParams.set('action', 'cache-ranged-response'); + url.searchParams.set('id', id); + url.hostname = REMOTE_HOST; + + appendAudio(w.document, url); + + await cachedRangeResponse; + + const fetchPromise = w.fetch('?action=use-cached-ranged-response', { mode: 'no-cors' }); + await promise_rejects_js(t, w.TypeError, fetchPromise); + + const loadImagePromise = loadImage('?action=use-cached-ranged-response', { doc: w.document }); + await promise_rejects_js(t, Error, loadImagePromise); +}, `Cached 416 response not allowed following no-cors ranged request`); diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js index 8d410c3..5234150 100644 --- a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js +++ b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js @@ -103,6 +103,37 @@ const iframe = await with_iframe(scope); const w = iframe.contentWindow; const id = Math.random() + ''; + const cachedRangeResponse = awaitMessage(t, w.navigator.serviceWorker, id); + + // Trigger a cross-origin range request using media and store the opaque 206 + // response in Cache Storage. + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('action', 'cache-ranged-response'); + url.searchParams.set('id', id); + url.hostname = REMOTE_HOST; + + appendAudio(w.document, url); + + await cachedRangeResponse; + + // Fetching should reject + const fetchPromise = w.fetch('?action=use-cached-ranged-response', { mode: 'no-cors' }); + await promise_rejects_js(t, w.TypeError, fetchPromise); + + // Script loading should error too + const loadScriptPromise = loadScript('?action=use-cached-ranged-response', { doc: w.document }); + await promise_rejects_js(t, Error, loadScriptPromise); + + assert_false(!!w.scriptExecuted, `Cached partial response shouldn't be executed`); +}, `Cached ranged response not allowed following no-cors ranged request`); + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const id = Math.random() + ''; const storedRangeResponse = awaitMessage(t, w.navigator.serviceWorker, id); // Trigger a range request using media
Regression Test / PoC
diff --git a/content/browser/cache_storage/cache_storage_cache_unittest.cc b/content/browser/cache_storage/cache_storage_cache_unittest.cc
index 01d0356a..5ce683c 100644
--- a/content/browser/cache_storage/cache_storage_cache_unittest.cc
+++ b/content/browser/cache_storage/cache_storage_cache_unittest.cc
@@ -64,6 +64,7 @@
#include "net/base/url_util.h"
#include "net/disk_cache/disk_cache.h"
#include "net/http/http_connection_info.h"
+#include "net/http/http_status_code.h"
#include "storage/browser/blob/blob_storage_context.h"
#include "storage/browser/quota/quota_manager_proxy.h"
#include "storage/browser/test/blob_test_utils.h"
@@ -3378,6 +3379,46 @@
EXPECT_TRUE(callback_response_->timing_allow_passed);
}
+TEST_P(CacheStorageCacheTestP, HasRangeRequested) {
+ EXPECT_TRUE(Keys());
+
+ // Put a response with `has_range_requested = false`.
+ blink::mojom::FetchAPIResponsePtr response = CreateNoBodyResponse();
+ response->has_range_requested = false;
+ EXPECT_TRUE(Put(no_body_request_, std::move(response)));
+
+ // Match the response and verify `has_range_requested` is preserved as
+ // `false`.
+ EXPECT_TRUE(Match(no_body_request_));
+ ASSERT_TRUE(callback_response_);
+ EXPECT_FALSE(callback_response_->has_range_requested);
+
+ // Put a response with `has_range_requested = true`.
+ blink::mojom::FetchAPIResponsePtr response2 = CreateBlobBodyResponse();
+ response2->has_range_requested = true;
+ EXPECT_TRUE(Put(body_request_, std::move(response2)));
+
+ // Match the response and verify `has_range_requested` is preserved as `true`.
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_);
+ EXPECT_TRUE(callback_response_->has_range_requested);
+
+ // Opaque 206 and 416 responses force `has_range_requested = true` even if
+ // stored with `has_range_requested = false`.
+ for (int status_code :
+ {net::HTTP_PARTIAL_CONTENT, net::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE}) {
+ blink::mojom::FetchAPIResponsePtr opaque_partial = CreateBlobBodyResponse();
+ opaque_partial->response_type = network::mojom::FetchResponseType::kOpaque;
+ opaque_partial->status_code = status_code;
+ opaque_partial->has_range_requested = false;
+ EXPECT_TRUE(Put(body_request_, std::move(opaque_partial)));
+
+ EXPECT_TRUE(Match(body_request_));
+ ASSERT_TRUE(callback_response_);
+ EXPECT_TRUE(callback_response_->has_range_requested);
+ }
+}
+
INSTANTIATE_TEST_SUITE_P(CacheStorageCacheTest,
CacheStorageCacheTestP,
::testing::Values(false, true));
diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js b/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js
index dfa537a2..2f25f72 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js
+++ b/third_party/blink/web_tests/external/wpt/fetch/range/resources/range-sw.js
@@ -31,6 +31,12 @@
case 'use-stored-ranged-response':
useStoredRangeResponse(event);
return;
+ case 'cache-ranged-response':
+ cacheRangedResponse(event);
+ return;
+ case 'use-cached-ranged-response':
+ useCachedRangeResponse(event);
+ return;
case 'broadcast-accept-encoding':
broadcastAcceptEncoding(event);
return;
@@ -148,6 +154,32 @@
}());
}
+const RANGE_CACHE_NAME = 'range-response-cache';
+const RANGE_CACHE_KEY = 'cached-ranged-response';
+
+function cacheRangedResponse(event) {
+ /** @type Request */
+ const request = event.request;
+ const id = new URL(request.url).searchParams.get('id');
+
+ event.waitUntil((async () => {
+ const response = await fetch(event.request);
+ const cache = await caches.open(RANGE_CACHE_NAME);
+ await cache.put(RANGE_CACHE_KEY, response);
+ broadcast({ id });
+ })());
+
+ // Just send back any response, it isn't important for the test.
+ event.respondWith(new Response(''));
+}
+
+function useCachedRangeResponse(event) {
+ event.respondWith((async () => {
+ const cache = await caches.open(RANGE_CACHE_NAME);
+ return cache.match(RANGE_CACHE_KEY);
+ })());
+}
+
function broadcastAcceptEncoding(event) {
/** @type Request */
const request = event.request;
diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js b/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js
index ca4436a..30d20628 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js
+++ b/third_party/blink/web_tests/external/wpt/fetch/range/sw-416.https.window.js
@@ -59,3 +59,30 @@
const loadImagePromise = loadImage('?action=use-stored-ranged-response', { doc: w.document });
await promise_rejects_js(t, Error, loadImagePromise);
}, `416 response not allowed following no-cors ranged request`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const id = Math.random() + '';
+ const cachedRangeResponse = awaitMessage(w.navigator.serviceWorker, id);
+
+ const url = new URL('partial-script.py', w.location);
+ url.searchParams.set('require-range', '1');
+ url.searchParams.set('range-not-satisfiable', '1');
+ url.searchParams.set('type', 'image/png');
+ url.searchParams.set('action', 'cache-ranged-response');
+ url.searchParams.set('id', id);
+ url.hostname = REMOTE_HOST;
+
+ appendAudio(w.document, url);
+
+ await cachedRangeResponse;
+
+ const fetchPromise = w.fetch('?action=use-cached-ranged-response', { mode: 'no-cors' });
+ await promise_rejects_js(t, w.TypeError, fetchPromise);
+
+ const loadImagePromise = loadImage('?action=use-cached-ranged-response', { doc: w.document });
+ await promise_rejects_js(t, Error, loadImagePromise);
+}, `Cached 416 response not allowed following no-cors ranged request`);
diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
index 8d410c3..5234150 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
+++ b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
@@ -103,6 +103,37 @@
const iframe = await with_iframe(scope);
const w = iframe.contentWindow;
const id = Math.random() + '';
+ const cachedRangeResponse = awaitMessage(t, w.navigator.serviceWorker, id);
+
+ // Trigger a cross-origin range request using media and store the opaque 206
+ // response in Cache Storage.
+ const url = new URL('partial-script.py', w.location);
+ url.searchParams.set('require-range', '1');
+ url.searchParams.set('action', 'cache-ranged-response');
+ url.searchParams.set('id', id);
+ url.hostname = REMOTE_HOST;
+
+ appendAudio(w.document, url);
+
+ await cachedRangeResponse;
+
+ // Fetching should reject
+ const fetchPromise = w.fetch('?action=use-cached-ranged-response', { mode: 'no-cors' });
+ await promise_rejects_js(t, w.TypeError, fetchPromise);
+
+ // Script loading should error too
+ const loadScriptPromise = loadScript('?action=use-cached-ranged-response', { doc: w.document });
+ await promise_rejects_js(t, Error, loadScriptPromise);
+
+ assert_false(!!w.scriptExecuted, `Cached partial response shouldn't be executed`);
+}, `Cached ranged response not allowed following no-cors ranged request`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const id = Math.random() + '';
const storedRangeResponse = awaitMessage(t, w.navigator.serviceWorker, id);
// Trigger a range request using media