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 <toyoshim@chromium.org>
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 <bashi@chromium.org>
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Commit-Queue: Takashi Toyoshima <toyoshim@chromium.org>
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