CVE-2026-11223
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/public/cpp/header_util.cc |
modified |
Files Changed
services/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_factory.ccservices/network/cors/cors_url_loader_factory.hservices/network/public/cpp/features.ccservices/network/public/cpp/features.hservices/network/public/cpp/header_util.cc
Patch
From 1917c17d0ad2fdc3e994a9aefd254b8db7bbe457 Mon Sep 17 00:00:00 2001 From: Takashi Toyoshima <[email protected]> Date: Wed, 22 Apr 2026 19:42:38 -0700 Subject: [PATCH] OOR-CORS: Enforce extra forbidden Sec- header checks This CL adds a new function ContainsForbiddenSecurityHeader in header_util to check for unauthorized Sec- headers from renderer. This function is used in both CorsURLLoaderFactory::IsValidRequest and CorsURLLoader::FollowRedirect to prevent renderer from injecting or modifying these headers, while permitting Client Hints and Sec-Purpose. Change-Id: Ia2923ccbddddba2657d72916a03e657c1785cd85 Bug: 494800494 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7760510 Reviewed-by: Kenichi Ishibashi <[email protected]> Reviewed-by: Yoshisato Yanagisawa <[email protected]> Commit-Queue: Takashi Toyoshima <[email protected]> Cr-Commit-Position: refs/heads/main@{#1619285} --- diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc index 3014a28f..6c236903 100644 --- a/services/network/cors/cors_url_loader.cc +++ b/services/network/cors/cors_url_loader.cc @@ -456,8 +456,17 @@ return; } + net::HttpRequestHeaders mutable_modified_headers = modified_headers; + if (!process_id_.is_browser() && + ContainsForbiddenSecurityHeader(mutable_modified_headers)) { + mojo::ReportBadMessage( + "CorsURLLoader: Forbidden Sec- header from renderer in FollowRedirect"); + HandleComplete(URLLoaderCompletionStatus(net::ERR_INVALID_ARGUMENT)); + return; + } + // Does not allow modifying headers that are stored in `cors_exempt_headers`. - for (const auto& header : modified_headers.GetHeaderVector()) { + for (const auto& header : mutable_modified_headers.GetHeaderVector()) { if (request_.cors_exempt_headers.HasHeader(header.key)) { LOG(WARNING) << "A client is trying to modify header value for '" << header.key << "', but it is not permitted."; @@ -480,9 +489,10 @@ request_.headers.RemoveHeader(name); request_.cors_exempt_headers.RemoveHeader(name); } - request_.headers.MergeFrom(modified_headers); - if (GetSecSharedStorageWritableHeader(modified_headers)) { + request_.headers.MergeFrom(mutable_modified_headers); + + if (GetSecSharedStorageWritableHeader(mutable_modified_headers)) { request_.shared_storage_writable_eligible = true; } else if (std::ranges::contains(removed_headers, kSecSharedStorageWritableHeader)) { diff --git a/services/network/cors/cors_url_loader_factory.cc b/services/network/cors/cors_url_loader_factory.cc index bf02d66..b83f8b4 100644 --- a/services/network/cors/cors_url_loader_factory.cc +++ b/services/network/cors/cors_url_loader_factory.cc @@ -583,7 +583,7 @@ } bool CorsURLLoaderFactory::IsValidRequest( - const ResourceRequest& request, + ResourceRequest& request, uint32_t options, const net::MutableNetworkTrafficAnnotationTag& traffic_annotation) { if (request.url.SchemeIs(url::kDataScheme)) { @@ -863,6 +863,13 @@ return false; } + if (!process_id_.is_browser() && + ContainsForbiddenSecurityHeader(request.headers)) { + mojo::ReportBadMessage( + "CorsURLLoaderFactory: Forbidden Sec- header from renderer"); + return false; + } + if (!AreRequestHeadersSafe(request.headers) || !AreRequestHeadersSafe(request.cors_exempt_headers)) { return false; diff --git a/services/network/cors/cors_url_loader_factory.h b/services/network/cors/cors_url_loader_factory.h index fb44c6f4..565a58aa 100644 --- a/services/network/cors/cors_url_loader_factory.h +++ b/services/network/cors/cors_url_loader_factory.h @@ -159,7 +159,7 @@ void DestroyCorsURLLoader(CorsURLLoader* loader); bool IsValidRequest( - const ResourceRequest& request, + ResourceRequest& request, uint32_t options, const net::MutableNetworkTrafficAnnotationTag& traffic_annotation); diff --git a/services/network/public/cpp/features.cc b/services/network/public/cpp/features.cc index 97cdcac..c4458bf6 100644 --- a/services/network/public/cpp/features.cc +++ b/services/network/public/cpp/features.cc @@ -652,4 +652,7 @@ "initial_doh_probe_timeout", base::Seconds(5)); +BASE_FEATURE(kRestrictForbiddenSecurityHeaders, + base::FEATURE_ENABLED_BY_DEFAULT); + } // namespace network::features diff --git a/services/network/public/cpp/features.h b/services/network/public/cpp/features.h index 55aacccf..3d11d098 100644 --- a/services/network/public/cpp/features.h +++ b/services/network/public/cpp/features.h @@ -445,6 +445,9 @@ COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES) BASE_DECLARE_FEATURE_PARAM(base::TimeDelta, kDelayInitialDohProbeTimeoutParam); +COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES) +BASE_DECLARE_FEATURE(kRestrictForbiddenSecurityHeaders); + } // namespace network::features #endif // SERVICES_NETWORK_PUBLIC_CPP_FEATURES_H_ diff --git a/services/network/public/cpp/header_util.cc b/services/network/public/cpp/header_util.cc index 18ced593..ca0abc8 100644 --- a/services/network/public/cpp/header_util.cc +++ b/services/network/public/cpp/header_util.cc @@ -4,16 +4,20 @@ #include "services/network/public/cpp/header_util.h" +#include <map> #include <string> #include <vector> #include "base/containers/fixed_flat_map.h" +#include "base/feature_list.h" +#include "base/metrics/histogram_functions.h" #include "base/strings/string_split.h" #include "base/strings/string_util.h" #include "net/base/mime_sniffer.h" #include "net/http/http_request_headers.h" #include "net/http/http_response_headers.h" #include "net/http/http_status_code.h" +#include "services/network/public/cpp/features.h" #include "services/network/public/mojom/url_response_head.mojom.h" #include "url/gurl.h" @@ -105,6 +109,94 @@ return true; } +bool ContainsForbiddenSecurityHeader(net::HttpRequestHeaders& headers) { + static const bool enabled = + base::FeatureList::IsEnabled(features::kRestrictForbiddenSecurityHeaders); + if (!enabled) { + return false; + } + + std::map<std::string, std::string> headers_to_truncate; + + auto sanitize_and_check_security_header = [&](std::string_view name, + std::string_view value) { + // Client Hints are harmless and set by the renderer. + if (base::StartsWith(name, "Sec-CH-", + base::CompareCase::INSENSITIVE_ASCII)) { + size_t size = value.size(); + base::UmaHistogramCounts10000("NetworkService.SecCHHeaderSize", size); + if (size > 1024) { + headers_to_truncate[std::string(name)] = + std::string(value.substr(0, 1024)); + } + return true; + } + // Sec-Purpose is used for prefetch hints and contains short strings. + if (base::EqualsCaseInsensitiveASCII(name, "Sec-Purpose")) { + return value.size() < 256; + } + // Browsing Topics API headers contain structured interest tokens. + if (base::EqualsCaseInsensitiveASCII(name, "Sec-Browsing-Topics")) { + return value.size() < 1024; + } + // Shared Storage and FLEDGE fetch headers use structured boolean "?1". + if (base::EqualsCaseInsensitiveASCII(name, "Sec-Shared-Storage-Writable") || + base::EqualsCaseInsensitiveASCII(name, "Sec-Ad-Auction-Fetch")) { + return value == "?1"; + } + // Shared Storage data origin headers contain origin URLs. + if (base::EqualsCaseInsensitiveASCII(name, + "Sec-Shared-Storage-Data-Origin")) { + return value.size() <= 267; + } + // Speculation Rules headers contain comma-separated tokens. + if (base::EqualsCaseInsensitiveASCII(name, "Sec-Speculation-Tags")) { + size_t size = value.size(); + base::UmaHistogramCounts10000( + "NetworkService.SecSpeculationTagsHeaderSize", size); + if (size > 2048) { + std::string_view truncated_value = value.substr(0, 2048); + size_t last_comma = truncated_value.rfind(',');
Regression Test / PoC
diff --git a/services/network/public/cpp/header_util_unittest.cc b/services/network/public/cpp/header_util_unittest.cc
index d876da32..fc2feddf 100644
--- a/services/network/public/cpp/header_util_unittest.cc
+++ b/services/network/public/cpp/header_util_unittest.cc
@@ -8,6 +8,7 @@
#include "base/strings/stringprintf.h"
#include "net/http/http_request_headers.h"
#include "net/http/http_response_headers.h"
+#include "services/network/public/cpp/features.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace network {
@@ -143,4 +144,174 @@
}
}
+TEST(HeaderUtilTest, ContainsForbiddenSecurityHeader) {
+ net::HttpRequestHeaders headers;
+
+ // Normal case
+ headers.SetHeader("Sec-CH-UA", "Normal Value");
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ std::string value;
+ auto value_opt = headers.GetHeader("Sec-CH-UA");
+ ASSERT_TRUE(value_opt.has_value());
+ value = *value_opt;
+ EXPECT_EQ(value, "Normal Value");
+
+ // Truncation case
+ std::string long_value(2000, 'a');
+ headers.SetHeader("Sec-CH-UA-Long", long_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ value_opt = headers.GetHeader("Sec-CH-UA-Long");
+ ASSERT_TRUE(value_opt.has_value());
+ value = *value_opt;
+ EXPECT_EQ(value.length(), 1024u);
+ EXPECT_EQ(value, long_value.substr(0, 1024));
+
+ // Boundary cases for Sec-CH-
+ std::string value_1023(1023, 'a');
+ headers.SetHeader("Sec-CH-UA-1023", value_1023);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-CH-UA-1023"), value_1023);
+
+ std::string value_1024(1024, 'a');
+ headers.SetHeader("Sec-CH-UA-1024", value_1024);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-CH-UA-1024"), value_1024);
+
+ std::string value_1025(1025, 'a');
+ headers.SetHeader("Sec-CH-UA-1025", value_1025);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(headers.GetHeader("Sec-CH-UA-1025")->length(), 1024u);
+
+ // Non-Sec-CH- header should not be truncated even if long
+ headers.SetHeader("X-Custom-Header", long_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ value_opt = headers.GetHeader("X-Custom-Header");
+ ASSERT_TRUE(value_opt.has_value());
+ value = *value_opt;
+ EXPECT_EQ(value.length(), 2000u);
+
+ // Sec-Shared-Storage-Data-Origin boundary cases
+ net::HttpRequestHeaders origin_headers;
+ std::string origin_267(267, 'a');
+ origin_headers.SetHeader("Sec-Shared-Storage-Data-Origin", origin_267);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(origin_headers));
+
+ std::string origin_268(268, 'a');
+ origin_headers.SetHeader("Sec-Shared-Storage-Data-Origin", origin_268);
+ EXPECT_TRUE(ContainsForbiddenSecurityHeader(origin_headers));
+
+ // Forbidden Sec- header
+ headers.SetHeader("Sec-Invalid", "value");
+ EXPECT_TRUE(ContainsForbiddenSecurityHeader(headers));
+}
+
+TEST(HeaderUtilTest,
+ ContainsForbiddenSecurityHeader_SecSpeculationTags_Truncation) {
+ net::HttpRequestHeaders headers;
+
+ // Normal case (< 2048)
+ std::string normal_value = "tag1,tag2";
+ headers.SetHeader("Sec-Speculation-Tags", normal_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ auto value_opt = headers.GetHeader("Sec-Speculation-Tags");
+ ASSERT_TRUE(value_opt.has_value());
+ EXPECT_EQ(*value_opt, normal_value);
+
+ // Truncation case with comma
+ std::string long_value = std::string(2000, 'a') + "," + std::string(100, 'b');
+ // total size = 2000 + 1 + 100 = 2101 > 2048
+ headers.SetHeader("Sec-Speculation-Tags", long_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ value_opt = headers.GetHeader("Sec-Speculation-Tags");
+ ASSERT_TRUE(value_opt.has_value());
+ std::string value = *value_opt;
+ EXPECT_LE(value.length(), 2048u);
+ // It should be truncated at the last comma before 2048.
+ // 2000 'a's + 1 comma = 2001 bytes. The next is 'b'.
+ // So it should truncate at the comma.
+ EXPECT_EQ(value, std::string(2000, 'a'));
+
+ // Truncation case without comma (fallback to 2048)
+ std::string very_long_tag(2500, 'c');
+ headers.SetHeader("Sec-Speculation-Tags", very_long_tag);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ value_opt = headers.GetHeader("Sec-Speculation-Tags");
+ ASSERT_TRUE(value_opt.has_value());
+ value = *value_opt;
+ EXPECT_EQ(value.length(), 2048u);
+ EXPECT_EQ(value, very_long_tag.substr(0, 2048));
+
+ // Boundary cases for Sec-Speculation-Tags
+ std::string value_2047(2047, 'a');
+ headers.SetHeader("Sec-Speculation-Tags", value_2047);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Speculation-Tags"), value_2047);
+
+ std::string value_2048(2048, 'a');
+ headers.SetHeader("Sec-Speculation-Tags", value_2048);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Speculation-Tags"), value_2048);
+
+ std::string value_2049(2049, 'a');
+ headers.SetHeader("Sec-Speculation-Tags", value_2049);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(headers.GetHeader("Sec-Speculation-Tags")->length(), 2048u);
+
+ // Boundary cases with comma
+ std::string comma_at_2047 = std::string(2047, 'a') + ",b";
+ headers.SetHeader("Sec-Speculation-Tags", comma_at_2047);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Speculation-Tags"), std::string(2047, 'a'));
+
+ std::string comma_at_2048 = std::string(2048, 'a') + ",b";
+ headers.SetHeader("Sec-Speculation-Tags", comma_at_2048);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Speculation-Tags"), std::string(2048, 'a'));
+}
+
+TEST(HeaderUtilTest, ContainsForbiddenSecurityHeader_SecAdAuction_Truncation) {
+ net::HttpRequestHeaders headers;
+
+ // Normal case (< 2048)
+ std::string normal_value = "auction-signal-data";
+ headers.SetHeader("Sec-Ad-Auction-Signals", normal_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ auto value_opt = headers.GetHeader("Sec-Ad-Auction-Signals");
+ ASSERT_TRUE(value_opt.has_value());
+ EXPECT_EQ(*value_opt, normal_value);
+
+ // Truncation case
+ std::string long_value(2500, 'a');
+ headers.SetHeader("Sec-Ad-Auction-Signals", long_value);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ value_opt = headers.GetHeader("Sec-Ad-Auction-Signals");
+ ASSERT_TRUE(value_opt.has_value());
+ std::string value = *value_opt;
+ EXPECT_EQ(value.length(), 2048u);
+ EXPECT_EQ(value, long_value.substr(0, 2048));
+
+ // Boundary cases for Sec-Ad-Auction-Signals
+ std::string value_2047(2047, 'a');
+ headers.SetHeader("Sec-Ad-Auction-Signals", value_2047);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Ad-Auction-Signals"), value_2047);
+
+ std::string value_2048(2048, 'a');
+ headers.SetHeader("Sec-Ad-Auction-Signals", value_2048);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(*headers.GetHeader("Sec-Ad-Auction-Signals"), value_2048);
+
+ std::string value_2049(2049, 'a');
+ headers.SetHeader("Sec-Ad-Auction-Signals", value_2049);
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+ EXPECT_EQ(headers.GetHeader("Sec-Ad-Auction-Signals")->length(), 2048u);
+
+ // Sec-Ad-Auction-Fetch should still be strictly checked
+ headers.SetHeader("Sec-Ad-Auction-Fetch", "?1");
+ EXPECT_FALSE(ContainsForbiddenSecurityHeader(headers));
+
+ headers.SetHeader("Sec-Ad-Auction-Fetch", "?0");
+ EXPECT_TRUE(ContainsForbiddenSecurityHeader(headers));
+}
+
} // namespace network
Original Bug Report
AreRequestHeadersSafe() missing Origin and Sec-* prefix blocking — compromised renderer forges Origin and Sec-Fetch-* headers via modified_headers in FollowRedirect
Report description
AreRequestHeadersSafe() missing Origin and Sec-* prefix blocking — compromised renderer forges Origin and Sec-Fetch-* headers via modified_headers in FollowRedirect
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/header_util.cc
The problem
Please describe the technical details of the vulnerability
IsRequestHeaderSafe() in header_util.cc blocks Host and Proxy-* headers but omits Origin and has no Sec- prefix check. A compromised renderer forges Origin and Sec-Fetch-* headers via modified_headers in FollowRedirect(), overwriting values the network service sets on cross-origin redirects.
Vulnerable file: header_util.cc — IsRequestHeaderSafe()
This violates docs/security/compromised-renderers.md: “Compromised renderers shouldn’t be able to control security sensitive HTTP request headers like Host, Origin, or Sec-Fetch-Site.”
On redirect: redirect_util.cc:87 sets Origin: null, then MergeFrom(modified_headers) at line 93 overwrites it. SetFetchMetadataHeaders writes correct Sec-Fetch-* before modified_headers overwrite them.
Additionally, SetSecFetchModeHeader in sec_header_helpers.cc:173 uses SetHeaderIfMissing, so a renderer-set Sec-Fetch-Mode persists on the initial request without any redirect.
Steps to reproduce:
- Check out stable tag:
git checkout 146.0.7680.154 git apply poc_patch.diffautoninja -C out/Default chrome- Start PoC servers:
python3 serve.py - Launch patched Chrome:
out/Default/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/hdr1-test http://localhost:8080/
- Click “Run Both” — observe forged headers in the server terminal:
Origin: http://127.0.0.1:8081(should benullon cross-origin redirect)Sec-Fetch-Site: same-origin(should becross-site)
The renderer patch injects forged Origin and Sec-Fetch-* into modified_headers during ResourceLoader::WillFollowRedirect() on cross-origin redirects. No flags needed.
Bisect:
Introducing commit: eb88b44f8e655 — Matt Menke, 2019-06-18, M77. Created kUnsafeHeaders blocklist without Origin or Sec- prefix. Parent commit has no blocklist. Moved to header_util.cc in 633e7e6277b64.
Affected: M77 through M146 (current stable).
Fix: Add Origin to kUnsafeHeaders and add Sec- prefix check in IsRequestHeaderSafe(), matching the existing Proxy- prefix check. Also change SetSecFetchModeHeader to overwrite=true. Attached as fix.diff.
Impact analysis
The PoC demonstrates two concrete bypasses:
- CSRF bypass: Victim server receives
Origin: http://127.0.0.1:8081(forged to match victim) instead ofnullon a cross-origin POST redirect (307). Server-side CSRF validation that checks Origin is defeated. - Fetch Metadata bypass: Victim server receives
Sec-Fetch-Site: same-origininstead ofcross-site. Server-side resource isolation policies recommended bycompromised-renderers.md(line 96) are defeated.
Variant analysis also found Access-Control-Request-Private-Network and Cookie (when 3P-blocked) are forgeable through the same gap.
The cause
What version of Chrome have you found the security issue in?
146.0.7680.154 (Stable)
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Other
How would you like to be publicly acknowledged for your report?
Tianyi Hu