Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Network
DescriptionIncorrect authorization in Network
ComponentNetwork
Sub-componentOOR-CORS
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker543464436
Fix commit00eafffe8ab9 (chromium/src) +282/-16
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedNH DEV
Reported2026-08-07 (per vendor advisory)
Fix landed2026-08-18 (public git)
Disclosed2026-09-22
Patch gap35 days — fix public in source before the advisory

Background

CORS preflight
An OPTIONS request 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 with Origin: null instead 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.

Key insight
The single mistake was using the raw 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

  1. Trigger a tainted preflight A page issues a cross-origin fetch that redirects to another origin, so Chrome sends the follow-up CORS preflight with Origin: null (tainted).
  2. 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 via AppendToCache.
  3. Issue an untainted request The page then makes a direct (untainted) CORS request to the same URL with the same method/headers.
  4. Skip preflight incorrectly CheckIfRequestCanSkipPreflight matches 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

An attacker-controlled or misconfigured endpoint can cause the network service to skip a required preflight, letting a cross-origin request bearing the real initiator origin reach a server that only ever authorized 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

FunctionChangeNotes
CorsURLLoaderTestWithTaintedOriginCacheKey
services/network/cors/cors_url_loader_unittest.cc
modified
CorsURLLoaderTestWithTaintedOriginCacheKey
services/network/cors/cors_url_loader_unittest.cc
modified
BadMessageTestHelper
services/network/cors/cors_url_loader_unittest.cc
modified
TEST_F
services/network/cors/cors_url_loader_unittest.cc
modified
TEST_F
services/network/cors/preflight_controller_unittest.cc
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/fetch/api/cors/cors-preflight-cache-tainted.any.js
  • services/network/cors/preflight_controller_unittest.cc
  • services/network/public/cpp/features.h
  • services/network/cors/cors_url_loader_unittest.cc
  • services/network/cors/preflight_controller.cc
  • services/network/public/cpp/features.cc

Audit Directions

  • Cache keys that omit security state
    Audit other caches keyed on request_initiator or origin for cases where a flag like tainted_ alters the effective origin (Origin: null) but is not folded into the key.
  • Redirect-mutated request state
    Review 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 asymmetry
    Verify 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.
From 00eafffe8ab98da76dce72a48466990eaf1c6971 Mon Sep 17 00:00:00 2001
From: Takashi Toyoshima <[email protected]>
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 <[email protected]>
Commit-Queue: Takashi Toyoshima <[email protected]>
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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-8003
  • fix.patch – suggested fix at the two key-derivation sites

Steps:

  1. python3 poc_server.py
  2. 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/
  3. Observe the poc_server.py terminal 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

View on issue tracker