CVE-2026-1504
Overview
Background
- Opaque response
- a cross-origin
no-corsresponse (network::mojom::FetchResponseType::kOpaque) whose status, headers, and body are supposed to be hidden from the requesting origin. - Range requests (206 / 416)
- a request carrying a
Rangeheader may receive206 Partial Contentor416 Range Not Satisfiable; the Fetch spec’s main-fetch step forbids serving a range-derived response to a request that did not itself ask for a range. - `HasRangeRequested()`
- a
ResourceResponseflag marking that the response was produced for a request that carried aRangeheader (e.g. a response a service worker fetched with a range and then returned for a different request). - `kBlockPartialResponseWithoutRange`
- a new Blink feature flag, enabled by default, gating the extended check added by this patch.
Root Cause Analysis
In Blink’s ResourceLoader (in third_party/blink/renderer/platform/loader/fetch/resource_loader.cc; the enclosing function is not named in the hunk, though the regression test reaches it through ResourceLoader::DidReceiveResponse), an existing check cancels a load with ResourceError::CancelledDueToAccessCheckError when an opaque response that was range-requested is delivered to an initial request that has no Range header. Before the patch, that check only matched HttpStatusCode() == 206, so an opaque 416 response produced by a range request could still be delivered to a non-range request, violating the same Fetch invariant.
The patch tightens the condition to match status 206 or, when kBlockPartialResponseWithoutRange is enabled, status 416, while keeping the kOpaque, HasRangeRequested(), and “initial request lacks Range” requirements; matching responses now go to HandleError(...) and the resource fails with kLoadError, as the parameterised test confirms. The commit describes this as making the handling of 206 and 416 symmetric and references whatwg/fetch issue 1906. The advisory names the Background Fetch API, but the diff only changes the generic Blink resource loader and contains no Background Fetch code, so the exact Background Fetch trigger is not shown by this diff.
206 and forgot the other range-specific status, 416; the fix extends the same guard to opaque 416 responses.Attack Path
- Obtain a range-derived opaque response
Attacker-controlled code (for example a service worker, inferred) fetches a cross-origin resource in
no-corsmode with aRangeheader that the server cannot satisfy, receiving an opaque416withHasRangeRequested()set. - Substitute it for a non-range request
That opaque response is supplied to a different request from the page that carries no
Rangeheader, the scenario the regression test models viaDidReceiveResponse. - Check bypassed pre-patch
Because only status
206was rejected, the416response passed the access check and was handed to the consumer instead of failing with an access-check error. - Cross-origin information exposure
Observable behavior of the delivered
416response (e.g. metadata reflecting the cross-origin resource’s size, or behavior in APIs like Background Fetch that consume it) could leak information about the cross-origin resource (inferred).
Impact Assessment
416 response could be delivered to a non-range request in the renderer’s resource loader, bypassing a Fetch-spec access check; the likely consequence is a cross-origin information leak to the attacker’s page (inferred), with the advisory tying it to the Background Fetch API. Preconditions appear to be ordinary web content able to substitute responses for its own requests (for example via a service worker, inferred), with no compromised renderer, flag, or extension required in a stock build since the new check is on by default. The vendor rates this as high severity (“Inappropriate implementation in Background Fetch API”); the advisory provided does not state in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ResourceLoaderBlockPartialResponseWithoutRangeTestthird_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc |
modified | |
ResourceLoaderBlockPartialResponseWithoutRangeTestthird_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc |
modified |
Files Changed
third_party/blink/common/features.ccthird_party/blink/public/common/features.hthird_party/blink/renderer/platform/loader/fetch/resource_loader.ccthird_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
Audit Directions
- Status-code enumerations in security checksLook for other access checks that key on a single status (e.g.
206) where related statuses (416,200withContent-Range, redirects) carry the same cross-origin information. - Response substitution via service workersAudit consumers of service-worker-provided responses (media loading, Background Fetch, Cache API, downloads) for whether they enforce the range/opaque invariant independently of
ResourceLoader, since the advisory’s Background Fetch path is not covered by this diff. - Renderer-only enforcementThis check lives in the renderer; review whether browser-side consumers of opaque range responses (e.g. Background Fetch, which may not use Blink’s
ResourceLoader) apply an equivalent check that a compromised renderer cannot skip.
Patch
From c0e8cebca540579fa734bbca6ffc1e280982857f Mon Sep 17 00:00:00 2001 From: Yoshisato Yanagisawa <[email protected]> Date: Wed, 14 Jan 2026 19:28:36 -0800 Subject: [PATCH] Block opaque 416 responses to non-range requests Extend the security check for opaque 206 responses to also cover 416 responses when the request does not contain a Range header. This makes the handling of these responses symmetric. This change is controlled by the 'kBlockPartialResponseWithoutRange' feature flag. See: https://github.com/whatwg/fetch/issues/1906 Bug: 474435504 Change-Id: Ifbc4b9b9ea9f45ef6b5a0d3705de5368a1ee0227 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7453440 Reviewed-by: Takashi Toyoshima <[email protected]> Commit-Queue: Yoshisato Yanagisawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1569484} --- diff --git a/third_party/blink/common/features.cc b/third_party/blink/common/features.cc index 05ce5f9..87955c8c 100644 --- a/third_party/blink/common/features.cc +++ b/third_party/blink/common/features.cc @@ -771,6 +771,9 @@ "FledgeOriginScopedKeyConfig", ""); +BASE_FEATURE(kBlockPartialResponseWithoutRange, + base::FEATURE_ENABLED_BY_DEFAULT); + // See in the header. BASE_FEATURE(kFledgeConsiderKAnonymity, base::FEATURE_DISABLED_BY_DEFAULT); BASE_FEATURE(kFledgeEnforceKAnonymity, base::FEATURE_DISABLED_BY_DEFAULT); diff --git a/third_party/blink/public/common/features.h b/third_party/blink/public/common/features.h index 1ed3d09..dfb44160 100644 --- a/third_party/blink/public/common/features.h +++ b/third_party/blink/public/common/features.h @@ -483,6 +483,9 @@ BLINK_COMMON_EXPORT BASE_DECLARE_FEATURE_PARAM(std::string, kFledgeOriginScopedKeyConfig); +// Block partial responses (206, 416) for requests without a Range header. +BLINK_COMMON_EXPORT BASE_DECLARE_FEATURE(kBlockPartialResponseWithoutRange); + // Configures FLEDGE to consider k-anonymity. If both // kFledgeConsiderKAnonymity and kFledgeEnforceKAnonymity are on it will be // enforced; if only kFledgeConsiderKAnonymity is on it will be simulated. diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_loader.cc b/third_party/blink/renderer/platform/loader/fetch/resource_loader.cc index b6460db..de5fcc3 100644 --- a/third_party/blink/renderer/platform/loader/fetch/resource_loader.cc +++ b/third_party/blink/renderer/platform/loader/fetch/resource_loader.cc @@ -950,7 +950,11 @@ // A response should not serve partial content if it was not requested via a // Range header: https://fetch.spec.whatwg.org/#main-fetch if (response.GetType() == network::mojom::FetchResponseType::kOpaque && - response.HttpStatusCode() == 206 && response.HasRangeRequested() && + (response.HttpStatusCode() == 206 || + (base::FeatureList::IsEnabled( + features::kBlockPartialResponseWithoutRange) && + response.HttpStatusCode() == 416)) && + response.HasRangeRequested() && !initial_request.HttpHeaderFields().Contains(http_names::kRange)) { HandleError(ResourceError::CancelledDueToAccessCheckError( response.CurrentRequestUrl(), ResourceRequestBlockedReason::kOther)); diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc b/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc index 0e9566e0..15e8006d 100644 --- a/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc +++ b/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc @@ -815,4 +815,52 @@ ExpectCnameAliasInfoMatching(info, loader); } +class ResourceLoaderBlockPartialResponseWithoutRangeTest + : public ResourceLoaderTest, + public testing::WithParamInterface<bool> { + public: + ResourceLoaderBlockPartialResponseWithoutRangeTest() { + scoped_feature_list_.InitWithFeatureState( + features::kBlockPartialResponseWithoutRange, GetParam()); + } + + private: + base::test::ScopedFeatureList scoped_feature_list_; +}; + +TEST_P(ResourceLoaderBlockPartialResponseWithoutRangeTest, + BlockOpaque416ResponseToNonRangeRequest) { + auto* properties = MakeGarbageCollected<TestResourceFetcherProperties>(); + FetchContext* context = MakeGarbageCollected<MockFetchContext>(); + auto* fetcher = MakeResourceFetcher(properties, context); + + KURL url("https://www.example.com/"); + ResourceRequest request(url); + request.SetRequestContext(mojom::blink::RequestContextType::FETCH); + ASSERT_FALSE(request.HttpHeaderFields().Contains(http_names::kRange)); + + FetchParameters params = FetchParameters::CreateForTest(std::move(request)); + Resource* resource = RawResource::Fetch(params, fetcher, nullptr); + ResourceLoader* loader = resource->Loader(); + + ResourceResponse response(url); + response.SetHttpStatusCode(416); + response.SetType(network::mojom::FetchResponseType::kOpaque); + response.SetHasRangeRequested(true); + + loader->DidReceiveResponse(WrappedResourceResponse(response), + mojo::ScopedDataPipeConsumerHandle(), + /*cached_metadata=*/std::nullopt); + + if (GetParam()) { + EXPECT_EQ(resource->GetStatus(), ResourceStatus::kLoadError); + } else { + EXPECT_EQ(resource->GetStatus(), ResourceStatus::kPending); + } +} + +INSTANTIATE_TEST_SUITE_P(All, + ResourceLoaderBlockPartialResponseWithoutRangeTest, + testing::Bool()); + } // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc b/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
index 0e9566e0..15e8006d 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
@@ -815,4 +815,52 @@
ExpectCnameAliasInfoMatching(info, loader);
}
+class ResourceLoaderBlockPartialResponseWithoutRangeTest
+ : public ResourceLoaderTest,
+ public testing::WithParamInterface<bool> {
+ public:
+ ResourceLoaderBlockPartialResponseWithoutRangeTest() {
+ scoped_feature_list_.InitWithFeatureState(
+ features::kBlockPartialResponseWithoutRange, GetParam());
+ }
+
+ private:
+ base::test::ScopedFeatureList scoped_feature_list_;
+};
+
+TEST_P(ResourceLoaderBlockPartialResponseWithoutRangeTest,
+ BlockOpaque416ResponseToNonRangeRequest) {
+ auto* properties = MakeGarbageCollected<TestResourceFetcherProperties>();
+ FetchContext* context = MakeGarbageCollected<MockFetchContext>();
+ auto* fetcher = MakeResourceFetcher(properties, context);
+
+ KURL url("https://www.example.com/");
+ ResourceRequest request(url);
+ request.SetRequestContext(mojom::blink::RequestContextType::FETCH);
+ ASSERT_FALSE(request.HttpHeaderFields().Contains(http_names::kRange));
+
+ FetchParameters params = FetchParameters::CreateForTest(std::move(request));
+ Resource* resource = RawResource::Fetch(params, fetcher, nullptr);
+ ResourceLoader* loader = resource->Loader();
+
+ ResourceResponse response(url);
+ response.SetHttpStatusCode(416);
+ response.SetType(network::mojom::FetchResponseType::kOpaque);
+ response.SetHasRangeRequested(true);
+
+ loader->DidReceiveResponse(WrappedResourceResponse(response),
+ mojo::ScopedDataPipeConsumerHandle(),
+ /*cached_metadata=*/std::nullopt);
+
+ if (GetParam()) {
+ EXPECT_EQ(resource->GetStatus(), ResourceStatus::kLoadError);
+ } else {
+ EXPECT_EQ(resource->GetStatus(), ResourceStatus::kPending);
+ }
+}
+
+INSTANTIATE_TEST_SUITE_P(All,
+ ResourceLoaderBlockPartialResponseWithoutRangeTest,
+ testing::Bool());
+
} // namespace blink
Original Bug Report
Leaking size of cross-origin resources via rangeless opaque responses using Service Workers and the Fetch API
VULNERABILITY DETAILS
When a cross-origin resource is used in an audio/video tag, a request containing the Range header asking for bytes=0- is issued. If the request is intercepted using a Service Worker and we respond with an arbitrary Content-Range header, e.g:
e.respondWith(new Response("A".repeat(size), {status: 206, headers: { "Content-Range": "bytes 0-5000/13337" }}));
Chrome will be tricked into thinking it got the first size bytes of the audio/video and then ask for the remaining bytes by issuing a new request containing the Range: bytes=${size}- header.
If we also intercept the following request and send it again using the Fetch API, the request will be sent to the cross-origin server containing the Range header and there will be two possible outcomes:
- The server will return a
416 Range Not Satisfiableresponse status code if the cross-origin response size is smaller thansizebytes. - The server will return a
206 Partial Contentresponse status code if the cross-origin response size is bigger thansizebytes.
We can then store this response and use the service worker to return it when a different resource is fetched (e.g., /mock.css with mode: "no-cors").
In the case of fetch requests, if the stored response contains a 206 status code, it will fail because Chrome has a security check in resource_loader.cc:950-958 that blocks opaque responses with status 206 when has_range_requested is true but the original request didn’t have a Range header.
However, if the stored response contains a 416 status code, this check is skipped because it only triggers for status code 206, not 416, causing the fetch to succeed normally.
This difference in behavior (206 throws an error, 416 succeeds) can be observed using a try/catch block, thus creating an oracle that allows an attacker to detect the possible outcomes mentioned above and leak the exact size of cross-origin resources that accept range requests.
The root cause is in third_party/blink/renderer/platform/loader/fetch/resource_loader.cc:950-958:
// A response should not serve partial content if it was not requested via a
// Range header: https://fetch.spec.whatwg.org/#main-fetch
if (response.GetType() == network::mojom::FetchResponseType::kOpaque &&
response.HttpStatusCode() == 206 && response.HasRangeRequested() &&
!initial_request.HttpHeaderFields().Contains(http_names::kRange)) {
HandleError(ResourceError::CancelledDueToAccessCheckError(
response.CurrentRequestUrl(), ResourceRequestBlockedReason::kOther));
return;
}
The check only blocks responses with status code 206, but does not block 416 responses, allowing the information leak.
In the PoC, the size of https://www.google.com/robots.txt is being brute-forced through a binary search attack.
This vulnerability is useful for XS-Search attacks. A real-world example is https://medium.com/@luanherrera/xs-searching-googles-bug-tracker-to-find-out-vulnerable-source-code-50d8135b7549 (more on https://github.com/xsleaks/xsleaks/wiki/Real-World-Examples).
For more context, this is a variation of https://crbug.com/chromium/990849 (which I reported a few years ago). I have also attached a video reproducing the attack (repro.mp4).
BISECT
By doing an initial bisect, I found that the issue was introduce between 800651 and 800665 (https://chromium.googlesource.com/chromium/src/+log/d474076dff70b3df138c423225c74cb011e750b7..676c68c320a484dc95ed5060d81af7b71d55153b).
After investigating, it became clear that the commit that introduced the issue is https://chromium.googlesource.com/chromium/src/+/5da0ed1e65305ab2c6d9de2bb4ce62f159520ba8, and it landed on M87.0.4241.0.
VERSION
Chrome Version: 143.0.7499.170 (Stable)
Chrome Version: 144.0.7559.31 (Beta)
Chrome Version: 145.0.7587.5 (Dev)
Chrome Version: 145.0.7618.0 (Canary)
Operating System: Windows 11 24H2
REPRODUCTION CASE
- Download the following files:
index.htmlandsw.js. - Move all files into the same folder.
- Serve the files using a web server (e.g.,
python -m http.server 8080). - Navigate to http://localhost:8080/index.html.
- The page will automatically register the service worker.
- Enter a URL in the input field (default is https://www.google.com/robots.txt) and click the
Runbutton. - The exact response size of the cross-origin resource will be leaked via a binary search attack, with results displayed on the page.
CREDIT INFORMATION
Reporter credit: Luan Herrera (@lbherrera_)
- http://localhost:8080/index.html
- https://chromium.googlesource.com/chromium/src/+/5da0ed1e65305ab2c6d9de2bb4ce62f159520ba8
- https://chromium.googlesource.com/chromium/src/+log/d474076dff70b3df138c423225c74cb011e750b7..676c68c320a484dc95ed5060d81af7b71d55153b
- https://crbug.com/chromium/990849
- https://fetch.spec.whatwg.org/#main-fetch
- https://github.com/xsleaks/xsleaks/wiki/Real-World-Examples
- https://medium.com/@luanherrera/xs-searching-googles-bug-tracker-to-find-out-vulnerable-source-code-50d8135b7549
- https://www.google.com/robots.txt