CVE-2026-87573
Overview
Background
- CORS-safelisted request header
- a request header (like
Accept,Content-Type, orRange) whose name/value pair is considered “simple” enough that a cross-originfetchmay send it WITHOUT first issuing a CORS preflight (OPTIONS) request. - `Range` "simple range header value"
- the Fetch spec’s strict grammar for a safelistable
Rangevalue: a case-sensitivebytes=prefix followed by a singlefirst-last(orfirst-) range with no sign, no suffix (-N) form, and no whitespace or commas. - `net::HttpUtil::ParseRangeHeader` vs `net::HttpUtil::ParseFetchSingleRange`
- the former is the permissive RFC-7233 parser (accepts signs, suffix ranges, and, on lowercased input, a case-insensitive unit), while the latter strictly implements the Fetch “simple range header value” algorithm.
- `net::HttpByteRange`
- the parsed range object exposing
first_byte_position(),last_byte_position(),IsSuffixByteRange(), andIsValid().
Root Cause Analysis
The range branch of IsCorsSafelistedHeader (in services/network/public/cpp/cors/cors.cc) decided whether a Range value was safelisted by lowercasing the value and delegating to net::HttpUtil::ParseRangeHeader, the general-purpose RFC-7233 parser. Because the value was first lowercased and the parser is permissive, several values that the Fetch “simple range header value” algorithm rejects were treated as safelisted: a case-insensitive prefix (BYTES=/Bytes=), a leading + sign on byte positions (bytes=+0-100, bytes=0-+100), -1 in the last-byte-position slot (bytes=0--1), and enormous numeric positions that overflow int64_t. The invariant violated is that a header is only allowed to skip CORS preflight when it exactly matches the spec’s simple-value grammar; the permissive parser let non-conforming values slip past.
The fix replaces the delegation with net::HttpUtil::ParseFetchSingleRange(value, /*allow_whitespace=*/false) operating on the original (case-sensitive) value, then rejects the result if it is a suffix range, invalid, or saturated to std::numeric_limits<int64_t>::max() (the overflow sentinel), so only strictly conforming values are safelisted.
Range parser (on lowercased input) as a stand-in for the Fetch spec’s stricter “simple range header value” grammar, which over-approximated the safelist and let malformed/oversized Range values bypass CORS preflight; the fix swaps in the spec-faithful ParseFetchSingleRange and adds an explicit overflow-sentinel check.Attack Path
- Craft a non-conforming Range value
An attacker’s page issues a cross-origin
fetchcarrying aRangeheader whose value is not spec-simple, e.g.BYTES=100-200,bytes=+0-100,bytes=0--1, or an int64-overflowingbytes=5-999999999999999999999. - Header wrongly safelisted
Pre-patch,
IsCorsSafelistedHeaderlowercases the value and accepts it vianet::HttpUtil::ParseRangeHeader, classifying the header as CORS-safelisted. - Preflight skipped
Because the header is deemed safelisted, the network stack sends the actual cross-origin request without a CORS
OPTIONSpreflight, so the target server never gets the chance to authorize the unusualRangeheader. - Server receives unexpected Range
The request reaches the cross-origin server with an attacker-chosen, non-standard
Rangevalue that should have required explicit opt-in; whether this yields further impact depends on that server’s handling (inferred).
Impact Assessment
Range header values while evading the CORS preflight that would normally gate such headers, weakening the CORS safelist invariant in the network service. The precondition is only that the victim visits or is served attacker-controlled script that issues the fetch; the concrete downstream consequence depends on the target server’s behavior and is not demonstrated by the patch (inferred). The advisory classifies this as improper input validation in the Network component and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/public/cpp/cors/cors.cc |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.jsservices/network/public/cpp/cors/cors_unittest.ccservices/network/public/cpp/cors/cors.cc
Audit Directions
- Permissive parser reused for a stricter specFlag any place that answers a Fetch/CORS “is this simple/safelisted?” question by reusing a general-purpose network parser (RFC-7233 ranges, RFC content-type, etc.); the general parser’s tolerance becomes a safelist over-approximation.
- Case-folding before a case-sensitive checkAudit code that lowercases (or otherwise normalizes) a value before matching a grammar that the spec defines as case-sensitive, since normalization silently accepts variants the spec rejects (e.g.
BYTES=). - Missing overflow/sentinel handling on parsed integersLook for parsed numeric fields consumed without checking the parser’s saturation/overflow sentinel (
std::numeric_limits<int64_t>::max()), where oversized inputs pass validation that assumed bounded values.
Patch
From b12b81d1c3bfa70d2f73d27d377dbab77952f322 Mon Sep 17 00:00:00 2001 From: Martin Verde <[email protected]> Date: Tue, 04 Aug 2026 19:22:15 -0700 Subject: [PATCH] [CORS] Tighten safelisted Range header value parsing IsCorsSafelistedHeader() delegated the Range value check to the permissive RFC-7233 parser net::HttpUtil::ParseRangeHeader, which accepts values that the Fetch "simple range header value" algorithm rejects: a leading sign on byte positions, "-1" as last-byte-pos, and a case-insensitive "bytes=" prefix. Replace the delegation with net::HttpUtil::ParseFetchSingleRange, which strictly implements the Fetch "simple range header value" algorithm (allow_whitespace=false). Additionally, reject values that overflow int64_t by checking for std::numeric_limits<int64_t>::max(). TAG=agy CONV=6c0675d2-d765-4cce-9da4-2bd9456132ec Bug: 513438970 Change-Id: Ie81a1b3b639cf9c5ae119be81c8956066e04fe69 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8189598 Reviewed-by: James Scott <[email protected]> Commit-Queue: Martin Pan-Verde <[email protected]> Reviewed-by: Kenichi Ishibashi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1673857} --- diff --git a/services/network/public/cpp/cors/cors.cc b/services/network/public/cpp/cors/cors.cc index ec75705..a176c188 100644 --- a/services/network/public/cpp/cors/cors.cc +++ b/services/network/public/cpp/cors/cors.cc @@ -6,6 +6,7 @@ #include "services/network/public/cpp/cors/cors.h" #include <algorithm> +#include <limits> #include <set> #include <string_view> #include <vector> @@ -391,22 +392,24 @@ return IsCorsSafelistedLowerCaseContentType( lower_value, is_ad_auction_trusted_signals_request); } else if (lower_name == "range") { - // A 'simple' range value is of the following form: 'bytes=\d+-(\d+)?'. - // We can use the regular range header parser with the following caveats: - // - No space characters or trailing commas - // - Only one range is provided - // - No suffix (bytes=-x) ranges - - if (std::ranges::any_of(lower_value, [](char c) { - return net::HttpUtil::IsLWS(c) || c == ','; - })) { + // A 'simple' range value is defined in the Fetch specification: + // https://fetch.spec.whatwg.org/#simple-range-header-value + // It must have an exact case-sensitive "bytes=" prefix. We also impose + // extra restrictions: + // - No whitespace (e.g. space or trailing comma). + // - No suffix ranges (e.g. bytes=-500). + std::optional<net::HttpByteRange> range = + net::HttpUtil::ParseFetchSingleRange(value, + /*allow_whitespace=*/false); + if (!range || range->IsSuffixByteRange() || !range->IsValid()) { return false; } - std::vector<net::HttpByteRange> ranges; - if (!net::HttpUtil::ParseRangeHeader(lower_value, &ranges)) + // `ParseFetchSingleRange()` will saturate values too high to represent as + // `int64_t` to `std::numeric_limits<int64_t>::max()`. + if (range->first_byte_position() == std::numeric_limits<int64_t>::max() || + range->last_byte_position() == std::numeric_limits<int64_t>::max()) { return false; - if (ranges.size() != 1 || ranges[0].IsSuffixByteRange()) - return false; + } return true; } else if (lower_name == "device-memory" || lower_name == "dpr" || lower_name == "downlink") { diff --git a/services/network/public/cpp/cors/cors_unittest.cc b/services/network/public/cpp/cors/cors_unittest.cc index 590471ee1..c0750f7 100644 --- a/services/network/public/cpp/cors/cors_unittest.cc +++ b/services/network/public/cpp/cors/cors_unittest.cc @@ -569,11 +569,12 @@ EXPECT_FALSE(IsCorsSafelistedHeader("range", "")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "500")); - // Case + // Case of header name is insensitive, but value unit must be exact lowercase. EXPECT_TRUE(IsCorsSafelistedHeader("range", "bytes=100-200")); EXPECT_TRUE(IsCorsSafelistedHeader("Range", "bytes=100-200")); EXPECT_TRUE(IsCorsSafelistedHeader("RANGE", "bytes=100-200")); - EXPECT_TRUE(IsCorsSafelistedHeader("range", "BYTES=100-200")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "BYTES=100-200")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "Bytes=100-200")); // Valid values EXPECT_TRUE(IsCorsSafelistedHeader("range", "bytes=100-")); @@ -585,12 +586,24 @@ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100-200,400-")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-50,100-")); - // Invalid ranges + // Invalid ranges and formatting variations EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=200-100")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-200--100")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-50-50")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-200")); EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+0-100")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=0-+100")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+100-200")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100-+200")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+100-")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+5-")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100--1")); + EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=0--1")); + EXPECT_FALSE(IsCorsSafelistedHeader( + "range", "bytes=999999999999999999999-999999999999999999999")); + EXPECT_FALSE( + IsCorsSafelistedHeader("range", "bytes=5-999999999999999999999")); // Invalid charset. EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes = 100-200")); diff --git a/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js b/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js index a0a0417d..a5cff84 100644 --- a/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js +++ b/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js @@ -66,6 +66,12 @@ ["bytes = 100-200", true], [",bytes=100-200", true], ["bytes=,100-200", true], + ["BYTES=100-200", true], + ["Bytes=100-200", true], + ["bytes=+0-100", true], + ["bytes=0-+100", true], + ["bytes=+5-", true], + ["bytes=0--1", true] ].forEach(([value, preflight = false]) => { safelist({"range": value}, preflight); });
Regression Test / PoC
diff --git a/services/network/public/cpp/cors/cors_unittest.cc b/services/network/public/cpp/cors/cors_unittest.cc
index 590471ee1..c0750f7 100644
--- a/services/network/public/cpp/cors/cors_unittest.cc
+++ b/services/network/public/cpp/cors/cors_unittest.cc
@@ -569,11 +569,12 @@
EXPECT_FALSE(IsCorsSafelistedHeader("range", ""));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "500"));
- // Case
+ // Case of header name is insensitive, but value unit must be exact lowercase.
EXPECT_TRUE(IsCorsSafelistedHeader("range", "bytes=100-200"));
EXPECT_TRUE(IsCorsSafelistedHeader("Range", "bytes=100-200"));
EXPECT_TRUE(IsCorsSafelistedHeader("RANGE", "bytes=100-200"));
- EXPECT_TRUE(IsCorsSafelistedHeader("range", "BYTES=100-200"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "BYTES=100-200"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "Bytes=100-200"));
// Valid values
EXPECT_TRUE(IsCorsSafelistedHeader("range", "bytes=100-"));
@@ -585,12 +586,24 @@
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100-200,400-"));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-50,100-"));
- // Invalid ranges
+ // Invalid ranges and formatting variations
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=200-100"));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-200--100"));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-50-50"));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=-200"));
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+0-100"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=0-+100"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+100-200"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100-+200"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+100-"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=+5-"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=100--1"));
+ EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes=0--1"));
+ EXPECT_FALSE(IsCorsSafelistedHeader(
+ "range", "bytes=999999999999999999999-999999999999999999999"));
+ EXPECT_FALSE(
+ IsCorsSafelistedHeader("range", "bytes=5-999999999999999999999"));
// Invalid charset.
EXPECT_FALSE(IsCorsSafelistedHeader("range", "bytes = 100-200"));
diff --git a/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js b/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js
index a0a0417d..a5cff84 100644
--- a/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js
+++ b/third_party/blink/web_tests/external/wpt/cors/cors-safelisted-request-header.any.js
@@ -66,6 +66,12 @@
["bytes = 100-200", true],
[",bytes=100-200", true],
["bytes=,100-200", true],
+ ["BYTES=100-200", true],
+ ["Bytes=100-200", true],
+ ["bytes=+0-100", true],
+ ["bytes=0-+100", true],
+ ["bytes=+5-", true],
+ ["bytes=0--1", true]
].forEach(([value, preflight = false]) => {
safelist({"range": value}, preflight);
});
Original Bug Report
CORS preflight bypass via non-spec Range header values
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: Chrome’s CORS implementation incorrectly safelists ‘Range’ headers that deviate from the Fetch specification’s strict grammar. By using leading signs, case-insensitive units, or specific sentinel values, an attacker can bypass mandatory CORS preflight requests to cross-origin servers.
Affected files:
services/network/public/cpp/cors/cors.ccnet/http/http_util.ccnet/http/http_byte_range.cc
Estimated timestamp from git blame: 2021-10-11
Summary
Chrome’s implementation of the CORS-safelisted Range header check is overly permissive, failing to strictly adhere to the Fetch specification’s definition of a ‘simple range header value’. The specification requires the value to follow the strict format: "bytes=" 1*DIGIT "-" *DIGIT.
Due to the use of a permissive integer parser in the network stack, Chrome accepts values with leading signs (e.g., +0), case-insensitive units (e.g., BYTES=), and internal sentinel values (e.g., -1). This allows cross-origin requests with non-conforming Range headers to bypass the CORS preflight (OPTIONS) mechanism.
Root Cause Analysis
The issue resides in the IsCorsSafelistedHeader function within services/network/public/cpp/cors/cors.cc. This function lowercases the header value and then delegates parsing to net::HttpUtil::ParseRangeHeader in net/http/http_util.cc.
- Leading Signs:
net::HttpUtil::ParseRangeHeaderusesbase::StringToInt64to convert the string offsets into integers.base::StringToInt64permits a leading+sign (e.g.,+0), which is forbidden by the Fetch spec’s1*DIGITrequirement. - Sentinel Collision: A header like
Range: bytes=0--1results in the second token being parsed as-1. Innet/http/http_byte_range.cc,-1is defined askPositionNotSpecified. Consequently,HttpByteRange::IsValid()treats this as an open-ended range (bytes=0-), which is a simple range and thus incorrectly safelisted. - Case-Insensitive Unit: The code lowercases the header value before processing, allowing
BYTES=0-100to be safelisted. The Fetch specification requires a byte-exact match for thebytes=prefix in the safelist check.
Potential Impact
An attacker can deliver non-standard Range header values to a cross-origin server without the server’s prior consent via a CORS preflight request. This bypasses a layer of defense-in-depth intended to protect servers from unexpected cross-origin traffic and could be used as a building block for parser-differential attacks.
Potential Reproduction Steps (Suggested)
- Host an attacker page on
https://attacker.test. - Execute a cross-origin fetch from the page:
fetch('https://victim.example/resource', { headers: { 'Range': 'bytes=+0-100' }, mode: 'cors' }); - Observe the network traffic to
victim.example. AGETrequest should be sent with the literalRange: bytes=+0-100header without a precedingOPTIONSpreflight request. - Confirm that other major browsers (e.g., Firefox) correctly trigger a preflight for the same request.
Suggested Fix
Modify net::HttpUtil::ParseRangeHeader (or the caller in services/network/public/cpp/cors/cors.cc) to use a stricter integer parser, such as net::ParseInt64 with net::ParseIntFormat::NON_NEGATIVE. Additionally, ensure that the bytes= prefix check is case-sensitive when performing CORS safelist validation.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.