Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Background Fetch API
DescriptionInappropriate implementation in Background Fetch API
ComponentBackground Fetch API
Bug ClassLogic Error
Tracker474435504
Fix commitc0e8cebca540 (chromium/src) +59/-1
CISA KEVNot listed
CreditedLuan Herrera (@lbherrera_)
Disclosed2026-01-27

Changed Functions

FunctionChangeNotes
ResourceLoaderBlockPartialResponseWithoutRangeTest
third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
modified
ResourceLoaderBlockPartialResponseWithoutRangeTest
third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
modified

Files Changed

  • third_party/blink/common/features.cc
  • third_party/blink/public/common/features.h
  • third_party/blink/renderer/platform/loader/fetch/resource_loader.cc
  • third_party/blink/renderer/platform/loader/fetch/resource_loader_test.cc
From c0e8cebca540579fa734bbca6ffc1e280982857f Mon Sep 17 00:00:00 2001
From: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
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 <toyoshim@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by he...@gmail.com

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:

  1. The server will return a 416 Range Not Satisfiable response status code if the cross-origin response size is smaller than size bytes.
  2. The server will return a 206 Partial Content response status code if the cross-origin response size is bigger than size bytes.

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

  1. Download the following files: index.html and sw.js.
  2. Move all files into the same folder.
  3. Serve the files using a web server (e.g., python -m http.server 8080).
  4. Navigate to http://localhost:8080/index.html.
  5. The page will automatically register the service worker.
  6. Enter a URL in the input field (default is https://www.google.com/robots.txt) and click the Run button.
  7. 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_)

View on issue tracker