High watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
6.5
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
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker474435504
Fix commitc0e8cebca540 (chromium/src) +59/-1
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedLuan Herrera (@lbherrera_)
Reported2026-01-09 · bounty $3000 (per vendor advisory)
Fix landed2026-01-15 (public git)
Disclosed2026-01-27
Patch gap12 days — fix public in source before the advisory

Background

Opaque response
a cross-origin no-cors response (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 Range header may receive 206 Partial Content or 416 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 ResourceResponse flag marking that the response was produced for a request that carried a Range header (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.

Key insight
The “no range-derived opaque response to a non-range request” check covered only 206 and forgot the other range-specific status, 416; the fix extends the same guard to opaque 416 responses.

Attack Path

  1. Obtain a range-derived opaque response Attacker-controlled code (for example a service worker, inferred) fetches a cross-origin resource in no-cors mode with a Range header that the server cannot satisfy, receiving an opaque 416 with HasRangeRequested() set.
  2. Substitute it for a non-range request That opaque response is supplied to a different request from the page that carries no Range header, the scenario the regression test models via DidReceiveResponse.
  3. Check bypassed pre-patch Because only status 206 was rejected, the 416 response passed the access check and was handed to the consumer instead of failing with an access-check error.
  4. Cross-origin information exposure Observable behavior of the delivered 416 response (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

The diff demonstrates that, pre-patch, an opaque range-derived 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

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

Audit Directions

  • Status-code enumerations in security checks
    Look for other access checks that key on a single status (e.g. 206) where related statuses (416, 200 with Content-Range, redirects) carry the same cross-origin information.
  • Response substitution via service workers
    Audit 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 enforcement
    This 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.
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