CVE-2026-95374
Overview
Background
- CORS preflight
- An
OPTIONSrequest the browser sends before certain cross-origin requests to confirm the server permits the actual method and headers. - Preflight cache
- A store in the network service (
PreflightController’s cache) that lets a request skip preflight when a prior successful preflight matches the same key (initiator origin, URL,NetworkIsolationKey, credentials mode, method, headers). - Tainted origin flag
- The
tainted_state set after a cross-origin redirect, causing the preflight to be sent withOrigin: nullinstead of the request initiator’s real origin. - Opaque origin
- A
url::Origin()(null) origin distinct from and never equal to any real origin, used here to isolate tainted cache entries.
Root Cause Analysis
In services/network/cors/preflight_controller.cc, both the cache-insertion path (AppendToCache) and the cache-lookup path (CheckIfRequestCanSkipPreflight) keyed entries on original_request_.request_initiator / request.request_initiator without consulting the tainted_ flag. A tainted preflight (sent with Origin: null after a cross-origin redirect) whose response returned Access-Control-Allow-Origin: null was therefore cached under the initiator’s real origin, and a later untainted request to the same URL matched that entry and skipped its own preflight — and symmetrically, an untainted cache entry could satisfy a tainted request. This violated the invariant that a cached preflight result must only authorize requests carrying the same effective origin that the preflight was evaluated against.
The fix conditions caching and lookup on !tainted_ (guarded by the kCorsPreflightCacheKeyTaintedOrigin killswitch), so tainted requests neither populate nor consult the initiator-keyed cache and are forced to perform their own preflight, matching WebKit and Gecko behavior.
request_initiator as the preflight cache key while ignoring the tainted_ flag, conflating two distinct effective origins (Origin: null vs. the real initiator). The fix distinguishes them by excluding tainted requests from the initiator-keyed cache entirely.Attack Path
- Trigger a tainted preflight
A page issues a cross-origin
fetchthat redirects to another origin, so Chrome sends the follow-up CORS preflight withOrigin: null(tainted). - Server allows null
The redirect target responds with
Access-Control-Allow-Origin: null, so the tainted preflight succeeds and is cached under the initiator’s real origin viaAppendToCache. - Issue an untainted request The page then makes a direct (untainted) CORS request to the same URL with the same method/headers.
- Skip preflight incorrectly
CheckIfRequestCanSkipPreflightmatches the cached tainted entry and the untainted request proceeds without its own preflight, despite the server never having authorized the real origin (and the reverse direction lets an untainted entry satisfy a tainted request).
Impact Assessment
Origin: null (or vice-versa) — an incorrect-authorization bypass of the CORS preflight check. It occurs in the network service process for kCors-mode requests, with the precondition that a cross-origin redirect produce a tainted preflight and that the same URL, NetworkIsolationKey, credentials mode, method, and headers recur across the tainted/untainted pair within the cache max-age.Changed Functions
| Function | Change | Notes |
|---|---|---|
CorsURLLoaderTestWithTaintedOriginCacheKeyservices/network/cors/cors_url_loader_unittest.cc |
modified | |
CorsURLLoaderTestWithTaintedOriginCacheKeyservices/network/cors/cors_url_loader_unittest.cc |
modified | |
BadMessageTestHelperservices/network/cors/cors_url_loader_unittest.cc |
modified | |
TEST_Fservices/network/cors/cors_url_loader_unittest.cc |
modified | |
TEST_Fservices/network/cors/preflight_controller_unittest.cc |
modified |
Files Changed
third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.jsservices/network/cors/preflight_controller_unittest.ccservices/network/public/cpp/features.hservices/network/cors/cors_url_loader_unittest.ccservices/network/cors/preflight_controller.ccservices/network/public/cpp/features.cc
Audit Directions
- Cache keys that omit security stateAudit other caches keyed on
request_initiatoror origin for cases where a flag liketainted_alters the effective origin (Origin: null) but is not folded into the key. - Redirect-mutated request stateReview paths where a cross-origin redirect changes the origin, credentials, or method a request carries, and confirm later reuse/matching accounts for the post-redirect state rather than the initial one.
- Insert/lookup asymmetryVerify that any cache’s insertion predicate and lookup predicate consult identical inputs, since divergence (one path checking a flag the other ignores) reintroduces cross-contamination.
Patch
From 00eafffe8ab98da76dce72a48466990eaf1c6971 Mon Sep 17 00:00:00 2001
From: Takashi Toyoshima <toyoshim@chromium.org>
Date: Mon, 17 Aug 2026 22:08:35 -0700
Subject: [PATCH] OOR-CORS: Use opaque origin as preflight cache key for tainted requests
Previously, PreflightController used the raw request_initiator as the
cache key for both cache insertion and lookup without checking the
tainted origin flag. As a result, a preflight response allowed for
'Origin: null' after a cross-origin redirect was cached under the
request initiator's origin, which incorrectly allowed subsequent
untainted requests to skip preflight (and vice-versa).
This CL updates PreflightController to use an opaque origin
(url::Origin()) as the cache key when the request origin is tainted.
This isolates tainted preflight cache entries from untainted requests
and matches the behavior of WebKit and Gecko.
A killswitch feature (kCorsPreflightCacheKeyTaintedOrigin) is added
and enabled by default.
Bug: 543464436
Change-Id: I0cfa366c26a20d9abee172d6cd4e8b05402ff891
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8260150
Reviewed-by: Kenichi Ishibashi <bashi@chromium.org>
Commit-Queue: Takashi Toyoshima <toyoshim@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1681108}
---
diff --git a/services/network/cors/preflight_controller.cc b/services/network/cors/preflight_controller.cc
index c8fdb86..01a39ea 100644
--- a/services/network/cors/preflight_controller.cc
+++ b/services/network/cors/preflight_controller.cc
@@ -43,6 +43,7 @@
#include "services/network/public/mojom/url_loader_network_service_observer.mojom.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "url/gurl.h"
+#include "url/origin.h"
namespace network::cors {
@@ -478,9 +479,12 @@
if (!(original_request_.load_flags & net::LOAD_DISABLE_CACHE) &&
net_error == net::OK) {
- controller_->AppendToCache(*original_request_.request_initiator,
- original_request_.url, network_isolation_key_,
- std::move(result));
+ if (!tainted_ || !base::FeatureList::IsEnabled(
+ features::kCorsPreflightCacheKeyTaintedOrigin)) {
+ controller_->AppendToCache(*original_request_.request_initiator,
+ original_request_.url,
+ network_isolation_key_, std::move(result));
+ }
}
CHECK(!detected_error_status.has_value() || net_error != net::OK);
@@ -614,6 +618,8 @@
: net::NetworkIsolationKey();
if (!RetrieveCacheFlags(request.load_flags) &&
+ (!tainted || !base::FeatureList::IsEnabled(
+ features::kCorsPreflightCacheKeyTaintedOrigin)) &&
cache_.CheckIfRequestCanSkipPreflight(
request.request_initiator.value(), request.url, network_isolation_key,
request.credentials_mode, request.method, request.headers,
diff --git a/services/network/public/cpp/features.cc b/services/network/public/cpp/features.cc
index 56689dab..c7231a0 100644
--- a/services/network/public/cpp/features.cc
+++ b/services/network/public/cpp/features.cc
@@ -193,6 +193,12 @@
BASE_FEATURE(kCorsNonWildcardRequestHeadersSupport,
base::FEATURE_DISABLED_BY_DEFAULT);
+// When enabled, CORS preflight cache keys take the tainted origin flag into
+// account (using an opaque / null origin), preventing tainted preflight
+// results from satisfying untainted requests.
+BASE_FEATURE(kCorsPreflightCacheKeyTaintedOrigin,
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
// Do not send TLS client certificates in CORS preflight. Omit all client certs
// and continue the handshake without sending one if requested.
BASE_FEATURE(kOmitCorsClientCert, base::FEATURE_DISABLED_BY_DEFAULT);
diff --git a/services/network/public/cpp/features.h b/services/network/public/cpp/features.h
index c376342..57643afb 100644
--- a/services/network/public/cpp/features.h
+++ b/services/network/public/cpp/features.h
@@ -78,6 +78,9 @@
BASE_DECLARE_FEATURE(kCorsNonWildcardRequestHeadersSupport);
COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES)
+BASE_DECLARE_FEATURE(kCorsPreflightCacheKeyTaintedOrigin);
+
+COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES)
BASE_DECLARE_FEATURE(kOmitCorsClientCert);
COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES)
diff --git a/services/network/cors/cors_url_loader_unittest.cc b/services/network/cors/cors_url_loader_unittest.cc
index 512a86f..4a571f8 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -94,6 +94,25 @@
CorsURLLoaderTestWithSafeRevalidation,
testing::Bool());
+class CorsURLLoaderTestWithTaintedOriginCacheKey
+ : public CorsURLLoaderTestBase,
+ public testing::WithParamInterface<bool> {
+ public:
+ CorsURLLoaderTestWithTaintedOriginCacheKey() {
+ feature_list_.InitWithFeatureState(
+ features::kCorsPreflightCacheKeyTaintedOrigin, GetParam());
+ }
+
+ bool IsTaintedOriginCacheKeyEnabled() const { return GetParam(); }
+
+ private:
+ base::test::ScopedFeatureList feature_list_;
+};
+
+INSTANTIATE_TEST_SUITE_P(All,
+ CorsURLLoaderTestWithTaintedOriginCacheKey,
+ testing::Bool());
+
class BadMessageTestHelper {
public:
BadMessageTestHelper()
@@ -2371,7 +2390,8 @@
EXPECT_TRUE(GetRequest().headers.HasHeader(net::HttpRequestHeaders::kOrigin));
}
-TEST_F(CorsURLLoaderTest, RestrictedPrefetchRedirectUpdatesIsolationInfo) {
+TEST_P(CorsURLLoaderTestWithTaintedOriginCacheKey,
+ RestrictedPrefetchRedirectUpdatesIsolationInfo) {
url::Origin initiator = url::Origin::Create(GURL("https://example.com"));
const GURL url("https://other.example.com/foo.png");
const GURL new_url("https://other.example.org/bar.png");
@@ -2448,18 +2468,43 @@
RunUntilCreateLoaderAndStartCalled();
- // Verify that the preflight cache now contains the entry for
- // other.example.org under expected_nik and NOT under the stale NIK!
- EXPECT_TRUE(
- network_context()
- ->cors_preflight_controller()
- ->GetPreflightCacheForTesting()
- .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
- EXPECT_FALSE(
- network_context()
- ->cors_preflight_controller()
- ->GetPreflightCacheForTesting()
- .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ if (IsTaintedOriginCacheKeyEnabled()) {
+ // When kCorsPreflightCacheKeyTaintedOrigin is enabled, tainted preflights
+ // are not cached, so no entry is added for new_url.
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ EXPECT_EQ(1u, network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .CountEntriesForTesting());
+ } else {
+ // When kCorsPreflightCacheKeyTaintedOrigin is disabled, the tainted
+ // preflight is keyed under `initiator`. Verify that the preflight cache
+ // contains the entry for other.example.org under expected_nik and NOT under
+ // the stale NIK.
+ EXPECT_TRUE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ EXPECT_EQ(2u, network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .CountEntriesForTesting());
+ }
// The actual redirected request (GET) to `new_url`
EXPECT_EQ(4, num_created_loaders());
diff --git a/services/network/cors/preflight_controller_unittest.cc b/services/network/cors/preflight_controller_unittest.cc
index ef04fb78..8001fcf 100644
--- a/services/network/cors/preflight_controller_unittest.cc
+++ b/services/network/cors/preflight_controller_unittest.cc
@@ -753,6 +753,105 @@
EXPECT_EQ(1u, access_count());
}
+TEST_F(PreflightControllerTest,
+ TaintedPreflightDoesNotSatisfyUntaintedRequest) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // Tainted preflight check succeeds because the test server returns
+ // Access-Control-Allow-Origin: null for "/tainted".
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Untainted preflight check must not hit the cache from the tainted request.
+ // A new preflight is sent, and fails because the server returns "null" which
+ // does not match test_initiator_origin().
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::ERR_FAILED, net_error());
+ ASSERT_TRUE(status());
+ EXPECT_EQ(mojom::CorsError::kPreflightAllowOriginMismatch,
+ status()->cors_error);
+ EXPECT_EQ(2u, access_count());
+}
+
+TEST_F(PreflightControllerTest,
+ TaintedPreflightDoesNotSatisfyUntaintedRequest_FeatureDisabled) {
+ base::test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndDisableFeature(
+ features::kCorsPreflightCacheKeyTaintedOrigin);
+
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // Tainted preflight check succeeds.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // With the feature disabled, the tainted preflight is incorrectly cached
+ // under request_initiator, allowing the untainted request to skip preflight.
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+}
+
+TEST_F(PreflightControllerTest,
+ UntaintedPreflightDoesNotSatisfyTaintedRequest) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/allow");
+ request.request_initiator = test_initiator_origin();
+
+ // Untainted preflight check succeeds (Access-Control-Allow-Origin:
+ // test_initiator_origin()).
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Tainted preflight check must not hit the cache from the untainted
+ // request. A new preflight is sent with Origin: null, and fails because the
+ // server returns Access-Control-Allow-Origin: test_initiator_origin().
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::ERR_FAILED, net_error());
+ ASSERT_TRUE(status());
+ EXPECT_EQ(mojom::CorsError::kPreflightAllowOriginMismatch,
+ status()->cors_error);
+ EXPECT_EQ(2u, access_count());
+}
+
+TEST_F(PreflightControllerTest, TaintedPreflightsAreNotCached) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // First tainted preflight check.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Second tainted preflight check: tainted results are not cached, so a new
+ // preflight request is performed.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(2u, access_count());
+}
+
TEST_F(PreflightControllerTest, CheckResponseWithNullHeaders) {
GURL url = GURL("https://google.com/finullurl");
const mojom::URLResponseHead response_head;
diff --git a/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js b/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js
new file mode 100644
index 0000000..7f1f5cf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js
@@ -0,0 +1,107 @@
+// META: script=/common/get-host-info.sub.js
+// META: script=/common/utils.js
+// META: script=../resources/utils.js
+
+const host_info = get_host_info();
+const redir_url = host_info.HTTP_REMOTE_ORIGIN + dirname(location.pathname) +
+ RESOURCES_DIR + 'redirect.py';
+const cors_url = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT +
+ dirname(location.pathname) + RESOURCES_DIR + 'preflight.py';
+
+promise_test((test) => {
+ const uuid_token = token();
+ const target_url = cors_url + '?token=' + uuid_token +
+ '&max_age=12000&allow_methods=POST' +
+ '&allow_headers=x-test-header';
+ const redirect_url = redir_url +
+ '?allow_headers=x-test-header&location=' + encodeURIComponent(target_url);
+
+ // Cross-origin redirect to target: preflight is sent with tainted origin
+ // (Origin: null).
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash')
+ .then(() => {
+ return fetch(new Request(redirect_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test1']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Tainted request performed preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for tainted request');
+ // Direct (untainted) request to target: must NOT reuse tainted
+ // preflight cache.
+ return fetch(new Request(target_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test2']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Untainted request performed new preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for untainted request');
+ });
+}, 'Tainted CORS preflight cache entry is not reused for untainted requests');
+
+promise_test((test) => {
+ const uuid_token = token();
+ const target_url = cors_url + '?token=' + uuid_token +
+ '&max_age=12000&allow_methods=POST' +
+ '&allow_headers=x-test-header';
+ const redirect_url = redir_url +
+ '?allow_headers=x-test-header&location=' + encodeURIComponent(target_url);
+
+ // Direct (untainted) request to target: preflight is sent with untainted
+ // origin.
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash')
+ .then(() => {
+ return fetch(new Request(target_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test1']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Untainted request performed preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for untainted request');
+ // Cross-origin redirected (tainted) request to target: must NOT
+ // reuse untainted preflight cache.
+ return fetch(new Request(redirect_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test2']]
... (file diff truncated)
Regression Test / PoC
diff --git a/services/network/cors/cors_url_loader_unittest.cc b/services/network/cors/cors_url_loader_unittest.cc
index 512a86f..4a571f8 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -94,6 +94,25 @@
CorsURLLoaderTestWithSafeRevalidation,
testing::Bool());
+class CorsURLLoaderTestWithTaintedOriginCacheKey
+ : public CorsURLLoaderTestBase,
+ public testing::WithParamInterface<bool> {
+ public:
+ CorsURLLoaderTestWithTaintedOriginCacheKey() {
+ feature_list_.InitWithFeatureState(
+ features::kCorsPreflightCacheKeyTaintedOrigin, GetParam());
+ }
+
+ bool IsTaintedOriginCacheKeyEnabled() const { return GetParam(); }
+
+ private:
+ base::test::ScopedFeatureList feature_list_;
+};
+
+INSTANTIATE_TEST_SUITE_P(All,
+ CorsURLLoaderTestWithTaintedOriginCacheKey,
+ testing::Bool());
+
class BadMessageTestHelper {
public:
BadMessageTestHelper()
@@ -2371,7 +2390,8 @@
EXPECT_TRUE(GetRequest().headers.HasHeader(net::HttpRequestHeaders::kOrigin));
}
-TEST_F(CorsURLLoaderTest, RestrictedPrefetchRedirectUpdatesIsolationInfo) {
+TEST_P(CorsURLLoaderTestWithTaintedOriginCacheKey,
+ RestrictedPrefetchRedirectUpdatesIsolationInfo) {
url::Origin initiator = url::Origin::Create(GURL("https://example.com"));
const GURL url("https://other.example.com/foo.png");
const GURL new_url("https://other.example.org/bar.png");
@@ -2448,18 +2468,43 @@
RunUntilCreateLoaderAndStartCalled();
- // Verify that the preflight cache now contains the entry for
- // other.example.org under expected_nik and NOT under the stale NIK!
- EXPECT_TRUE(
- network_context()
- ->cors_preflight_controller()
- ->GetPreflightCacheForTesting()
- .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
- EXPECT_FALSE(
- network_context()
- ->cors_preflight_controller()
- ->GetPreflightCacheForTesting()
- .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ if (IsTaintedOriginCacheKeyEnabled()) {
+ // When kCorsPreflightCacheKeyTaintedOrigin is enabled, tainted preflights
+ // are not cached, so no entry is added for new_url.
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ EXPECT_EQ(1u, network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .CountEntriesForTesting());
+ } else {
+ // When kCorsPreflightCacheKeyTaintedOrigin is disabled, the tainted
+ // preflight is keyed under `initiator`. Verify that the preflight cache
+ // contains the entry for other.example.org under expected_nik and NOT under
+ // the stale NIK.
+ EXPECT_TRUE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), expected_nik));
+ EXPECT_FALSE(
+ network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .DoesEntryExistForTesting(initiator, new_url.spec(), stale_nik));
+ EXPECT_EQ(2u, network_context()
+ ->cors_preflight_controller()
+ ->GetPreflightCacheForTesting()
+ .CountEntriesForTesting());
+ }
// The actual redirected request (GET) to `new_url`
EXPECT_EQ(4, num_created_loaders());
diff --git a/services/network/cors/preflight_controller_unittest.cc b/services/network/cors/preflight_controller_unittest.cc
index ef04fb78..8001fcf 100644
--- a/services/network/cors/preflight_controller_unittest.cc
+++ b/services/network/cors/preflight_controller_unittest.cc
@@ -753,6 +753,105 @@
EXPECT_EQ(1u, access_count());
}
+TEST_F(PreflightControllerTest,
+ TaintedPreflightDoesNotSatisfyUntaintedRequest) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // Tainted preflight check succeeds because the test server returns
+ // Access-Control-Allow-Origin: null for "/tainted".
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Untainted preflight check must not hit the cache from the tainted request.
+ // A new preflight is sent, and fails because the server returns "null" which
+ // does not match test_initiator_origin().
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::ERR_FAILED, net_error());
+ ASSERT_TRUE(status());
+ EXPECT_EQ(mojom::CorsError::kPreflightAllowOriginMismatch,
+ status()->cors_error);
+ EXPECT_EQ(2u, access_count());
+}
+
+TEST_F(PreflightControllerTest,
+ TaintedPreflightDoesNotSatisfyUntaintedRequest_FeatureDisabled) {
+ base::test::ScopedFeatureList scoped_feature_list;
+ scoped_feature_list.InitAndDisableFeature(
+ features::kCorsPreflightCacheKeyTaintedOrigin);
+
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // Tainted preflight check succeeds.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // With the feature disabled, the tainted preflight is incorrectly cached
+ // under request_initiator, allowing the untainted request to skip preflight.
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+}
+
+TEST_F(PreflightControllerTest,
+ UntaintedPreflightDoesNotSatisfyTaintedRequest) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/allow");
+ request.request_initiator = test_initiator_origin();
+
+ // Untainted preflight check succeeds (Access-Control-Allow-Origin:
+ // test_initiator_origin()).
+ PerformPreflightCheck(request, /*tainted=*/false);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Tainted preflight check must not hit the cache from the untainted
+ // request. A new preflight is sent with Origin: null, and fails because the
+ // server returns Access-Control-Allow-Origin: test_initiator_origin().
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::ERR_FAILED, net_error());
+ ASSERT_TRUE(status());
+ EXPECT_EQ(mojom::CorsError::kPreflightAllowOriginMismatch,
+ status()->cors_error);
+ EXPECT_EQ(2u, access_count());
+}
+
+TEST_F(PreflightControllerTest, TaintedPreflightsAreNotCached) {
+ ResourceRequest request;
+ request.mode = mojom::RequestMode::kCors;
+ request.credentials_mode = mojom::CredentialsMode::kOmit;
+ request.url = GetURL("/tainted");
+ request.request_initiator = test_initiator_origin();
+
+ // First tainted preflight check.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(1u, access_count());
+
+ // Second tainted preflight check: tainted results are not cached, so a new
+ // preflight request is performed.
+ PerformPreflightCheck(request, /*tainted=*/true);
+ EXPECT_EQ(net::OK, net_error());
+ ASSERT_FALSE(status());
+ EXPECT_EQ(2u, access_count());
+}
+
TEST_F(PreflightControllerTest, CheckResponseWithNullHeaders) {
GURL url = GURL("https://google.com/finullurl");
const mojom::URLResponseHead response_head;
diff --git a/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js b/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js
new file mode 100644
index 0000000..7f1f5cf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js
@@ -0,0 +1,107 @@
+// META: script=/common/get-host-info.sub.js
+// META: script=/common/utils.js
+// META: script=../resources/utils.js
+
+const host_info = get_host_info();
+const redir_url = host_info.HTTP_REMOTE_ORIGIN + dirname(location.pathname) +
+ RESOURCES_DIR + 'redirect.py';
+const cors_url = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT +
+ dirname(location.pathname) + RESOURCES_DIR + 'preflight.py';
+
+promise_test((test) => {
+ const uuid_token = token();
+ const target_url = cors_url + '?token=' + uuid_token +
+ '&max_age=12000&allow_methods=POST' +
+ '&allow_headers=x-test-header';
+ const redirect_url = redir_url +
+ '?allow_headers=x-test-header&location=' + encodeURIComponent(target_url);
+
+ // Cross-origin redirect to target: preflight is sent with tainted origin
+ // (Origin: null).
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash')
+ .then(() => {
+ return fetch(new Request(redirect_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test1']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Tainted request performed preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for tainted request');
+ // Direct (untainted) request to target: must NOT reuse tainted
+ // preflight cache.
+ return fetch(new Request(target_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test2']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Untainted request performed new preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for untainted request');
+ });
+}, 'Tainted CORS preflight cache entry is not reused for untainted requests');
+
+promise_test((test) => {
+ const uuid_token = token();
+ const target_url = cors_url + '?token=' + uuid_token +
+ '&max_age=12000&allow_methods=POST' +
+ '&allow_headers=x-test-header';
+ const redirect_url = redir_url +
+ '?allow_headers=x-test-header&location=' + encodeURIComponent(target_url);
+
+ // Direct (untainted) request to target: preflight is sent with untainted
+ // origin.
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash')
+ .then(() => {
+ return fetch(new Request(target_url, {
+ mode: 'cors',
+ method: 'POST',
+ headers: [['x-test-header', 'test1']]
+ }));
+ })
+ .then((resp) => {
+ assert_equals(resp.status, 200, 'Response status is 200');
+ assert_equals(resp.headers.get('x-did-preflight'), '1',
+ 'Untainted request performed preflight');
+ return fetch(cors_url + '?token=' + uuid_token + '&clear-stash');
+ })
+ .then((res) => res.text())
+ .then((txt) => {
+ assert_equals(txt, '1',
+ 'Preflight stash was recorded for untainted request');
+ // Cross-origin redirected (tainted) request to target: must NOT
... (truncated)
Original Bug Report
CORS preflight cache keyed on raw request_initiator, not byte-serialized origin -- tainted (`Origin: null`) preflight satisfies untainted request
Security Bug
VULNERABILITY DETAILS
The CORS-preflight cache is keyed on the raw request_initiator url::Origin
at both store (services/network/cors/preflight_controller.cc:474,
origin/main) and lookup (:610-611). The preflight Origin request header
(:180) and the preflight access-check (:291) use the tainted-adjusted
origin
(tainted ? url::Origin() : *request_initiator). Per Fetch §4.10
“CORS-preflight cache”, the entry key is the request’s byte-serialized
origin, which is "null" when the tainted-origin flag is set after a
cross-origin redirect.
Consequence: a preflight that target T approved for Origin: null (reached
via O→R→T cross-origin redirect, R attacker-controlled) is cached under O’s
request_initiator. A subsequent direct (untainted) fetch(T/api, {method:'DELETE'}) from O hits that cache entry, skips preflight, and
sends a credentialed DELETE to T with Origin: http://O – an origin T’s
preflight handler never approved. The reverse direction (O-approved entry
satisfies a tainted null request) also holds.
The actual-response ACAO check at cors_url_loader.cc:594-606 correctly uses
the tainted-adjusted origin, so response read is still blocked. Impact is
blind credentialed side-effect (CSRF-class) on non-simple methods, not
response disclosure.
VERSION
Chrome Version: 149.0.7827.155
Channel: Stable
Operating System: Linux x86_64, kernel 6.12.13-amd64
REPRODUCTION CASE
Attachments:
poc_server.py– Python3, no root required, listens on 127.0.0.1:8001-8003fix.patch– suggested fix at the two key-derivation sites
Steps:
python3 poc_server.py- Launch Chrome with a fresh profile (so the preflight cache is empty):
chrome --user-data-dir=/tmp/poc-prof http://127.0.0.1:8001/ - Observe the
poc_server.pyterminal output.
OBSERVED (bug present)
O GET / Origin=- serve attacker page
R OPTIONS /redir Origin=http://127.0.0.1:8001 preflight (R allows O)
R DELETE /redir Origin=http://127.0.0.1:8001 -> 307 http://127.0.0.1:8003/api
T OPTIONS /api Origin=null preflight ACCEPTED (ACAO: null, Max-Age: 300)
T DELETE /api Origin=null side-effect (tainted path; expected)
T DELETE /api Origin=http://127.0.0.1:8001 [OBSERVED] DELETE with Origin=O reached T without T ever preflight-approving O (tainted preflight cached under O's key)
EXPECTED (spec-compliant / bug absent)
...
T DELETE /api Origin=null side-effect (tainted path; expected)
T OPTIONS /api Origin=http://127.0.0.1:8001 [OK: BUG ABSENT] preflight DENIED for non-null origin
(Step-2 fetch misses the cache because a spec-compliant key would be
("null", T/api, NIK); fresh preflight with Origin: O is sent and T denies
it; the second DELETE never reaches T.)
ROOT CAUSE
services/network/cors/preflight_controller.cc:
- :488-490
cache_.AppendEntry(*original_request_.request_initiator, ...)– store - :624-628
cache_.CheckIfRequestCanSkipPreflight(request.request_initiator.value(), ...)– lookup - :178-180 / :286-292 use
tainted ? url::Origin() : *request_initiator– header + access check
services/network/cors/preflight_result.cc:236-259
EnsureAllowedRequest re-checks credentials_mode, method, headers – no
tainted re-check. services/network/cors/preflight_cache.{h,cc} has no
reference to tainted at all.
SUGGESTED FIX
See attached fix.patch. Use tainted ? url::Origin() : request_initiator at
both :488 and :625 so the cache key matches the byte-serialized origin used
for the header and access check. Alternative: add bool tainted as a fourth
component of the PreflightCache::cache_ key tuple (preflight_cache.h:84-88).
SECURITY IMPACT
Web-reachable (no compromised renderer, no flags, no user interaction beyond
visiting attacker page). Bypasses CORS-preflight-as-CSRF-defense for targets
whose actual-request handler rejects Origin: null while the preflight
handler accepts it (i.e. preflight allows null, but the state-changing
endpoint itself origin-checks the request header and only trusts named
origins). Response read remains blocked by the actual-response ACAO check;
impact is server-side state change only.
CREDIT INFORMATION
Reporter credit: NH DEV