Medium watch CVSS 3.1 chrome Cross Origin 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in CORS
DescriptionIncorrect authorization in CORS
ComponentCORS
Sub-componentOOR-CORS
Bug ClassCross Origin
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker499230506
Fix commit2ef3dc14657f (chromium/src) +295/-31
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-03 (per vendor advisory)
Fix landed2026-08-03 (public git)
Disclosed2026-09-08
Patch gap36 days — fix public in source before the advisory

Background

CORS access check
the CheckAccess step in CorsURLLoader that verifies a cross-origin response carries a valid Access-Control-Allow-Origin header before the response is exposed to the initiating renderer.
Revalidation / `is_revalidating`
a ResourceRequest boolean that marks a conditional cache-revalidation request (one carrying If-None-Match / If-Modified-Since), used by CORS logic to special-case those requests and their 304 Not Modified responses.
304 revalidation exemption
the rule that a 304 reply to a revalidation is treated as “not a new cross-origin fetch” and is therefore allowed to skip the CORS access check, since it just confirms an already-cached resource.
`kSafeRevalidation`
the feature flag (base::FEATURE_ENABLED_BY_DEFAULT) added by this patch that switches revalidation from a renderer-self-reported flag to network-service-constructed, structured metadata (revalidation_etag, revalidation_last_modified).

Root Cause Analysis

Before the patch, CorsURLLoader decided whether a 304 response could bypass the CORS access check using request_.is_revalidating directly: const bool is_304_for_revalidation = request_.is_revalidating && response_head->headers && response_head->headers->response_code() == 304;, and CheckAccess was skipped whenever fetch_cors_flag_ && !is_304_for_revalidation was false. Because is_revalidating is a plain boolean set by Blink (and thus attacker-controllable from a compromised or malicious renderer) and the raw conditional headers were likewise set directly by Blink, a renderer could self-report is_revalidating = true to claim the revalidation exemption without any legitimate cache state, causing a cross-origin 304 to be delivered even though it had no Access-Control-Allow-Origin header — the CORS authorization invariant (every cross-origin response is access-checked unless it is a genuine 304 revalidation) was violated. The same spoofable flag was also fed into the preflight/header-safety logic (CorsUnsafeNotForbiddenRequestHeaderNames, CreateAccessControlRequestHeadersHeader, EnsureAllowedCrossOriginHeaders, CheckIfRequestCanSkipPreflight) so that conditional headers were exempted from triggering a preflight.

The fix introduces IsRevalidatingRequest, which under kSafeRevalidation returns true only when validated structured metadata (revalidation_etag / revalidation_last_modified) is present, forces the header-check path to treat the request as non-revalidating (IsRevalidatingForHeadersCheck returns false), and moves construction of the If-None-Match / If-Modified-Since headers into the network service itself. This works because the exemption is no longer granted on the strength of a renderer-set boolean alone, and the metadata is validated in the mojom deserialization path before it can be trusted.

Key insight
The single core mistake was gating a CORS access-check bypass on is_revalidating, a boolean the untrusted renderer self-reports, so a renderer could claim the 304 revalidation exemption with no genuine cache state; the fix stops trusting that flag for security decisions and instead requires validated, network-service-managed revalidation metadata.

Attack Path

  1. Craft a revalidation-flagged cross-origin request A malicious or compromised renderer issues a cross-origin request via ResourceRequest with request.is_revalidating = true set, but without any legitimate revalidation metadata (as exercised by SafeRevalidationIgnoresSpoofedIsRevalidating, which sets is_revalidating = true with the comment // Spoofed flag without metadata!).
  2. Induce or receive a 304 response The target cross-origin server (or an intermediary) returns a 304 Not Modified response that lacks an Access-Control-Allow-Origin header, so response_head->headers->response_code() == 304 holds.
  3. Trigger the exemption to skip `CheckAccess` Pre-patch, is_304_for_revalidation is true because request_.is_revalidating is true, so fetch_cors_flag_ && !is_304_for_revalidation is false and CheckAccess is never called, delivering the cross-origin response to the renderer without CORS authorization instead of failing with net::ERR_FAILED.
  4. Leak cross-origin response state (inferred) Receiving the 304 without an access check exposes cross-origin metadata/response state that CORS is meant to withhold, usable as an information-disclosure oracle; the specific data recoverable and any further escalation are (inferred) and not demonstrated by the diff.

Impact Assessment

An attacker controlling a renderer gains incorrect CORS authorization: a cross-origin 304 response is exposed to the renderer process without the required Access-Control-Allow-Origin check, breaking the same-origin/CORS boundary within the compromised renderer’s context. Preconditions are the ability to set is_revalidating on an outgoing cross-origin request (a compromised or malicious renderer, or content that can drive such a request) and a 304 response from the cross-origin target. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. Any escalation beyond the CORS-authorization bypass the patch demonstrates is (inferred).

Changed Functions

FunctionChangeNotes
if
services/network/cors/cors_url_loader.cc
modified

Files Changed

  • third_party/blink/renderer/platform/loader/fetch/resource_request.h
  • services/network/public/cpp/features.h
  • tools/metrics/histograms/metadata/network/enums.xml
  • services/network/public/cpp/url_request_mojom_traits.h
  • third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
  • services/network/cors/cors_url_loader_unittest.cc
  • services/network/public/cpp/resource_request.h
  • services/network/prefetch_matches.cc
  • services/network/cors/cors_url_loader.cc
  • services/network/public/cpp/url_request_mojom_traits_unittest.cc
  • third_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.cc
  • services/network/public/cpp/url_request_mojom_traits.cc
  • services/network/public/mojom/url_request.mojom
  • services/network/cors/preflight_controller.cc
  • services/network/public/cpp/features.cc
  • services/network/public/cpp/resource_request.cc

Audit Directions

  • Renderer-self-reported flags gating security decisions
    Audit every place the network/CORS layer reads a Blink-set boolean (like is_revalidating, or trusted_params-derived flags) to skip or relax an access/authorization check; a flag the renderer controls must never by itself grant a check bypass.
  • Access-check exemptions on special response codes
    Review other branches that skip CheckAccess or CORS enforcement for particular statuses (e.g. 304, redirects, 204) and confirm the exemption is tied to server-observed, network-service-managed state rather than request-side hints.
  • Header/metadata trusted across the mojom boundary
    Check that new ResourceRequest fields carrying header-derived values are validated in the StructTraits Read path (non-empty, net::HttpUtil::IsValidHeaderValue) before use, mirroring the revalidation_etag / revalidation_last_modified validation, to prevent header injection or spoofed-metadata bypasses.
From 2ef3dc14657fe82f7f6ce443e2367b80a14ae851 Mon Sep 17 00:00:00 2001
From: Takashi Toyoshima <[email protected]>
Date: Mon, 03 Aug 2026 03:13:07 -0700
Subject: [PATCH] OOR-CORS: Safe revalidation handling via structured metadata

This CL introduces a feature flag `kSafeRevalidation` (enabled by
default) and refactors cache revalidation handling to prevent CORS
bypass vulnerabilities.

Instead of relying on Blink to directly set raw conditional headers
(`If-None-Match`, `If-Modified-Since`) and self-report a spoofable
`is_revalidating` flag, Blink now passes structured metadata
(`revalidation_etag` and `revalidation_last_modified`) in
`ResourceRequest`.

When `kSafeRevalidation` is enabled, Network Service ignores the
renderer-supplied `is_revalidating` flag and safely constructs
conditional headers and handles 304 / CORS access checks based on the
presence of structured revalidation metadata.

Bug: 499230506
Change-Id: Ic390e9d17009df94ecde283e433b9891a5d6888c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8160285
Reviewed-by: Adam Rice <[email protected]>
Commit-Queue: Takashi Toyoshima <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1672530}
---
diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc
index 33b672a..16ada06 100644
--- a/services/network/cors/cors_url_loader.cc
+++ b/services/network/cors/cors_url_loader.cc
@@ -75,6 +75,14 @@
   kDisallowedHeader
 };
 
+bool IsRevalidatingRequest(const ResourceRequest& request) {
+  if (base::FeatureList::IsEnabled(features::kSafeRevalidation)) {
+    return request.revalidation_etag.has_value() ||
+           request.revalidation_last_modified.has_value();
+  }
+  return request.is_revalidating;
+}
+
 // Returns std::nullopt when a preflight isn't needed. Otherwise returns the
 // reason why a preflight is needed.
 std::optional<PreflightRequiredReason> NeedsPreflight(
@@ -99,8 +107,13 @@
       request.trusted_params &&
       request.trusted_params->is_ad_auction_trusted_signals_request;
 
+  const bool is_revalidating_for_headers =
+      base::FeatureList::IsEnabled(features::kSafeRevalidation)
+          ? false
+          : request.is_revalidating;
+
   if (!CorsUnsafeNotForbiddenRequestHeaderNames(
-           request.headers.GetHeaderVector(), request.is_revalidating,
+           request.headers.GetHeaderVector(), is_revalidating_for_headers,
            is_ad_auction_trusted_signals_request)
            .empty()) {
     return PreflightRequiredReason::kDisallowedHeader;
@@ -611,7 +624,7 @@
 
   // See 10.7.4 of https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
   const bool is_304_for_revalidation =
-      request_.is_revalidating && response_head->headers &&
+      IsRevalidatingRequest(request_) && response_head->headers &&
       response_head->headers->response_code() == 304;
   if (fetch_cors_flag_ && !is_304_for_revalidation) {
     const auto result = CheckAccess(
@@ -1115,6 +1128,17 @@
 
   network_loader_start_time_ = base::TimeTicks::Now();
 
+  if (base::FeatureList::IsEnabled(features::kSafeRevalidation)) {
+    if (request_.revalidation_etag) {
+      request_.headers.SetHeader(net::HttpRequestHeaders::kIfNoneMatch,
+                                 *request_.revalidation_etag);
+    }
+    if (request_.revalidation_last_modified) {
+      request_.headers.SetHeader(net::HttpRequestHeaders::kIfModifiedSince,
+                                 *request_.revalidation_last_modified);
+    }
+  }
+
   if (sync_network_loader_factory_) {
     sync_network_loader_factory_->CreateLoaderAndStartWithSyncClient(
         network_loader_.BindNewPipeAndPassReceiver(), request_id_, options_,
diff --git a/services/network/cors/preflight_controller.cc b/services/network/cors/preflight_controller.cc
index 8c0c1316..c8fdb86 100644
--- a/services/network/cors/preflight_controller.cc
+++ b/services/network/cors/preflight_controller.cc
@@ -70,6 +70,13 @@
   return headers->GetNormalizedHeader(header_name);
 }
 
+bool IsRevalidatingForHeadersCheck(const ResourceRequest& request) {
+  if (base::FeatureList::IsEnabled(features::kSafeRevalidation)) {
+    return false;
+  }
+  return request.is_revalidating;
+}
+
 // Algorithm step 3 of the CORS-preflight fetch,
 // https://fetch.spec.whatwg.org/#cors-preflight-fetch-0, that requires
 //  - CORS-safelisted request-headers excluded
@@ -129,7 +136,7 @@
       header_names::kAccessControlRequestMethod, request.method);
 
   std::string request_headers = CreateAccessControlRequestHeadersHeader(
-      request.headers, request.is_revalidating,
+      request.headers, IsRevalidatingForHeadersCheck(request),
       IsAdAuctionTrustedSignalsRequest(request));
   if (!request_headers.empty()) {
     preflight_request->headers.SetHeader(
@@ -319,7 +326,7 @@
     return status;
 
   return result.EnsureAllowedCrossOriginHeaders(
-      original_request.headers, original_request.is_revalidating,
+      original_request.headers, IsRevalidatingForHeadersCheck(original_request),
       non_wildcard_request_headers_support,
       IsAdAuctionTrustedSignalsRequest(original_request));
 }
@@ -610,7 +617,8 @@
       cache_.CheckIfRequestCanSkipPreflight(
           request.request_initiator.value(), request.url, network_isolation_key,
           request.credentials_mode, request.method, request.headers,
-          request.is_revalidating, net_log, acam_preflight_spec_conformant,
+          IsRevalidatingForHeadersCheck(request), net_log,
+          acam_preflight_spec_conformant,
           IsAdAuctionTrustedSignalsRequest(request))) {
     std::move(callback).Run(net::OK, std::nullopt, false);
     return;
diff --git a/services/network/prefetch_matches.cc b/services/network/prefetch_matches.cc
index 4744ac4f..c9db583 100644
--- a/services/network/prefetch_matches.cc
+++ b/services/network/prefetch_matches.cc
@@ -91,6 +91,8 @@
   DO_FIELD(previews_state) __VA_ARGS__                             \
   DO_FIELD(upgrade_if_insecure) __VA_ARGS__                        \
   DO_FIELD(is_revalidating) __VA_ARGS__                            \
+  DO_FIELD(revalidation_etag) __VA_ARGS__                           \
+  DO_FIELD(revalidation_last_modified) __VA_ARGS__                  \
   DO_FIELD(throttling_profile_id) __VA_ARGS__                      \
   DO_FIELD(fetch_window_id) __VA_ARGS__                            \
   DO_FIELD(devtools_request_id) __VA_ARGS__                        \
@@ -215,7 +217,9 @@
   kPermissionsPolicy = 65,
   kClientSideContentDecodingEnabled = 66,
   kIsReloadNavigation = 68,
-  kMaxValue = kIsReloadNavigation,
+  kRevalidationEtag = 69,
+  kRevalidationLastModified = 70,
+  kMaxValue = kRevalidationLastModified,
 };
 // LINT.ThenChange(//tools/metrics/histograms/metadata/network/enums.xml:PrefetchMatchesResourceRequestField)
 
@@ -261,6 +265,9 @@
     {Fields::kpreviews_state, FieldsForUma::kPreviewsState},
     {Fields::kupgrade_if_insecure, FieldsForUma::kUpgradeIfInsecure},
     {Fields::kis_revalidating, FieldsForUma::kIsRevalidating},
+    {Fields::krevalidation_etag, FieldsForUma::kRevalidationEtag},
+    {Fields::krevalidation_last_modified,
+     FieldsForUma::kRevalidationLastModified},
     {Fields::kthrottling_profile_id, FieldsForUma::kThrottlingProfileId},
     {Fields::kfetch_window_id, FieldsForUma::kFetchWindowId},
     {Fields::kdevtools_request_id, FieldsForUma::kDevtoolsRequestId},
diff --git a/services/network/public/cpp/features.cc b/services/network/public/cpp/features.cc
index 460f107..d304bebe4 100644
--- a/services/network/public/cpp/features.cc
+++ b/services/network/public/cpp/features.cc
@@ -629,4 +629,6 @@
 BASE_FEATURE(kBrowserInitiatedFileUploadValidation,
              base::FEATURE_ENABLED_BY_DEFAULT);
 
+BASE_FEATURE(kSafeRevalidation, 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 01b280a6..ce37f68 100644
--- a/services/network/public/cpp/features.h
+++ b/services/network/public/cpp/features.h
@@ -430,6 +430,9 @@
 COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES)
 BASE_DECLARE_FEATURE(kBrowserInitiatedFileUploadValidation);
 
+COMPONENT_EXPORT(NETWORK_CPP_FLAGS_AND_SWITCHES)
+BASE_DECLARE_FEATURE(kSafeRevalidation);
+
 }  // namespace network::features
 
 #endif  // SERVICES_NETWORK_PUBLIC_CPP_FEATURES_H_
diff --git a/services/network/public/cpp/resource_request.cc b/services/network/public/cpp/resource_request.cc
index 0530b6b..8a8e916 100644
--- a/services/network/public/cpp/resource_request.cc
+++ b/services/network/public/cpp/resource_request.cc
@@ -365,6 +365,8 @@
          previews_state == request.previews_state &&
          upgrade_if_insecure == request.upgrade_if_insecure &&
          is_revalidating == request.is_revalidating &&
+         revalidation_etag == request.revalidation_etag &&
+         revalidation_last_modified == request.revalidation_last_modified &&
          throttling_profile_id == request.throttling_profile_id &&
          fetch_window_id == request.fetch_window_id &&
          devtools_request_id == request.devtools_request_id &&
diff --git a/services/network/public/cpp/resource_request.h b/services/network/public/cpp/resource_request.h
index 4f973e6..684b6e2f 100644
--- a/services/network/public/cpp/resource_request.h
+++ b/services/network/public/cpp/resource_request.h
@@ -258,6 +258,8 @@
   int previews_state = 0;
   bool upgrade_if_insecure = false;
   bool is_revalidating = false;
+  std::optional<std::string> revalidation_etag;
+  std::optional<std::string> revalidation_last_modified;
   std::optional<base::UnguessableToken> throttling_profile_id;
   std::optional<base::UnguessableToken> fetch_window_id;
   std::optional<std::string> devtools_request_id;
diff --git a/services/network/public/cpp/url_request_mojom_traits.cc b/services/network/public/cpp/url_request_mojom_traits.cc
index 7ca184d..0ae84efa 100644
--- a/services/network/public/cpp/url_request_mojom_traits.cc
+++ b/services/network/public/cpp/url_request_mojom_traits.cc
@@ -13,6 +13,7 @@
 #include "mojo/public/cpp/base/file_path_mojom_traits.h"
 #include "mojo/public/cpp/base/time_mojom_traits.h"
 #include "mojo/public/cpp/base/unguessable_token_mojom_traits.h"
+#include "net/http/http_util.h"
 #include "services/network/public/cpp/crash_keys.h"
 #include "services/network/public/cpp/http_request_headers_mojom_traits.h"
 #include "services/network/public/cpp/isolation_info_mojom_traits.h"
@@ -203,6 +204,22 @@
   out->previews_state = data.previews_state();
   out->upgrade_if_insecure = data.upgrade_if_insecure();
   out->is_revalidating = data.is_revalidating();
+  if (!data.ReadRevalidationEtag(&out->revalidation_etag) ||
+      !data.ReadRevalidationLastModified(&out->revalidation_last_modified)) {
+    return false;
+  }
+  if (out->revalidation_etag.has_value()) {
+    if (out->revalidation_etag->empty() ||
+        !net::HttpUtil::IsValidHeaderValue(*out->revalidation_etag)) {
+      return false;
+    }
+  }
+  if (out->revalidation_last_modified.has_value()) {
+    if (out->revalidation_last_modified->empty() ||
+        !net::HttpUtil::IsValidHeaderValue(*out->revalidation_last_modified)) {
+      return false;
+    }
+  }
   out->is_fetch_like_api = data.is_fetch_like_api();
   out->is_fetch_later_api = data.is_fetch_later_api();
   out->is_favicon = data.is_favicon();
diff --git a/services/network/public/cpp/url_request_mojom_traits.h b/services/network/public/cpp/url_request_mojom_traits.h
index 248ec02d..00fc19e 100644
--- a/services/network/public/cpp/url_request_mojom_traits.h
+++ b/services/network/public/cpp/url_request_mojom_traits.h
@@ -371,6 +371,14 @@
   static bool is_revalidating(const network::ResourceRequest& request) {
     return request.is_revalidating;
   }
+  static const std::optional<std::string>& revalidation_etag(
+      const network::ResourceRequest& request) {
+    return request.revalidation_etag;
+  }
+  static const std::optional<std::string>& revalidation_last_modified(
+      const network::ResourceRequest& request) {
+    return request.revalidation_last_modified;
+  }
   static const std::optional<base::UnguessableToken>& throttling_profile_id(
       const network::ResourceRequest& request) {
     return request.throttling_profile_id;
diff --git a/services/network/public/mojom/url_request.mojom b/services/network/public/mojom/url_request.mojom
index a75173c..b3ea7e7 100644
--- a/services/network/public/mojom/url_request.mojom
+++ b/services/network/public/mojom/url_request.mojom
@@ -444,6 +444,10 @@
   // some CORS logic for a revalidating request.
   bool is_revalidating;
 
+  // Metadata passed for safe revalidation handling in Network Service.
+  string? revalidation_etag;
+  string? revalidation_last_modified;
+
   // The profile ID of network conditions to throttle the network request.
   mojo_base.mojom.UnguessableToken? throttling_profile_id;
 
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
index 67c7543e..d83be5b5 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_fetcher.cc
@@ -1697,16 +1697,27 @@
               revalidating_request.GetCacheMode());
     if (revalidating_request.GetCacheMode() ==
         mojom::blink::FetchCacheMode::kValidateCache) {
-      revalidating_request.SetHttpHeaderField(http_names::kCacheControl,
-                                              AtomicString("max-age=0"));
+      if (!base::FeatureList::IsEnabled(network::features::kSafeRevalidation)) {
+        revalidating_request.SetHttpHeaderField(http_names::kCacheControl,
+                                                AtomicString("max-age=0"));
+      }
     }
   }
-  if (!last_modified.empty()) {
-    revalidating_request.SetHttpHeaderField(http_names::kIfModifiedSince,
-                                            last_modified);
-  }
-  if (!e_tag.empty()) {
-    revalidating_request.SetHttpHeaderField(http_names::kIfNoneMatch, e_tag);
+  if (base::FeatureList::IsEnabled(network::features::kSafeRevalidation)) {
+    if (!last_modified.empty()) {
+      revalidating_request.SetRevalidationLastModified(last_modified);
+    }
+    if (!e_tag.empty()) {
+      revalidating_request.SetRevalidationEtag(e_tag);
+    }
+  } else {
+    if (!last_modified.empty()) {
+      revalidating_request.SetHttpHeaderField(http_names::kIfModifiedSince,
+                                              last_modified);
+    }
+    if (!e_tag.empty()) {
+      revalidating_request.SetHttpHeaderField(http_names::kIfNoneMatch, e_tag);
+    }
   }
 
   resource->SetRevalidatingRequest(revalidating_request);
diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_request.h b/third_party/blink/renderer/platform/loader/fetch/resource_request.h
index 1f83160..9a9d26a 100644
--- a/third_party/blink/renderer/platform/loader/fetch/resource_request.h
+++ b/third_party/blink/renderer/platform/loader/fetch/resource_request.h
@@ -429,6 +429,14 @@
 
   bool IsRevalidating() const { return is_revalidating_; }
   void SetIsRevalidating(bool value) { is_revalidating_ = value; }
+  const String& RevalidationEtag() const { return revalidation_etag_; }
+  void SetRevalidationEtag(const String& etag) { revalidation_etag_ = etag; }
+  const String& RevalidationLastModified() const {
+    return revalidation_last_modified_;
+  }
+  void SetRevalidationLastModified(const String& last_modified) {
+    revalidation_last_modified_ = last_modified;
+  }
   void SetIsAutomaticUpgrade(bool is_automatic_upgrade) {
     is_automatic_upgrade_ = is_automatic_upgrade;
   }
@@ -675,6 +683,8 @@
   bool priority_incremental_ : 1;
   bool upgrade_if_insecure_ : 1;
   bool is_revalidating_ : 1;
+  String revalidation_etag_;
+  String revalidation_last_modified_;
   bool is_automatic_upgrade_ : 1;
   bool is_from_origin_dirty_style_sheet_ : 1;
   bool is_fetch_like_api_ : 1;
diff --git a/third_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.cc b/third_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.cc
index d5338ab..95f6dca7 100644
--- a/third_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.cc
@@ -6,6 +6,7 @@
 
 #include <string_view>
 
+#include "base/feature_list.h"
 #include "mojo/public/cpp/bindings/receiver.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "mojo/public/cpp/system/data_pipe.h"
@@ -14,6 +15,7 @@
 #include "net/filter/source_stream_type.h"
 #include "net/http/http_request_headers.h"
 #include "net/http/http_util.h"
+#include "services/network/public/cpp/features.h"
 #include "services/network/public/cpp/is_potentially_trustworthy.h"
 #include "services/network/public/cpp/optional_trust_token_params.h"
 #include "services/network/public/cpp/resource_request.h"
@@ -39,6 +41,7 @@
 #include "third_party/blink/renderer/platform/loader/fetch/resource_request.h"
 #include "third_party/blink/renderer/platform/loader/fetch/trust_token_params_conversion.h"
 #include "third_party/blink/renderer/platform/network/encoded_form_data.h"
+#include "third_party/blink/renderer/platform/network/http_names.h"
 #include "third_party/blink/renderer/platform/network/wrapped_data_pipe_getter.h"
 
 namespace blink {
@@ -261,7 +264,19 @@
   dest->url = GURL(src.Url());
   dest->site_for_cookies = src.SiteForCookies();
   dest->upgrade_if_insecure = src.UpgradeIfInsecure();
-  dest->is_revalidating = src.IsRevalidating();
+  if (base::FeatureList::IsEnabled(network::features::kSafeRevalidation)) {
+    if (src.IsRevalidating()) {
+      if (!src.RevalidationEtag().IsNull()) {
+        dest->revalidation_etag = src.RevalidationEtag().Latin1();
+      }
+      if (!src.RevalidationLastModified().IsNull()) {
+        dest->revalidation_last_modified =
+            src.RevalidationLastModified().Latin1();
+      }
+    }
+  } else {
... (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 41414ce..512a86f 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -54,6 +54,46 @@
 
 class CorsURLLoaderTest : public CorsURLLoaderTestBase {};
 
+class CorsURLLoaderTestWithSafeRevalidation
+    : public CorsURLLoaderTestBase,
+      public testing::WithParamInterface<bool> {
+ public:
+  CorsURLLoaderTestWithSafeRevalidation() {
+    feature_list_.InitWithFeatureState(features::kSafeRevalidation, GetParam());
+  }
+
+  bool IsSafeRevalidationEnabled() const { return GetParam(); }
+
+  void SetRevalidationMetadata(ResourceRequest& request,
+                               const std::string& etag,
+                               const std::string& last_modified) {
+    if (IsSafeRevalidationEnabled()) {
+      if (!etag.empty()) {
+        request.revalidation_etag = etag;
+      }
+      if (!last_modified.empty()) {
+        request.revalidation_last_modified = last_modified;
+      }
+    } else {
+      request.is_revalidating = true;
+      if (!etag.empty()) {
+        request.headers.SetHeader(net::HttpRequestHeaders::kIfNoneMatch, etag);
+      }
+      if (!last_modified.empty()) {
+        request.headers.SetHeader(net::HttpRequestHeaders::kIfModifiedSince,
+                                  last_modified);
+      }
+    }
+  }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+};
+
+INSTANTIATE_TEST_SUITE_P(All,
+                         CorsURLLoaderTestWithSafeRevalidation,
+                         testing::Bool());
+
 class BadMessageTestHelper {
  public:
   BadMessageTestHelper()
@@ -1838,10 +1878,9 @@
             url::Origin::Create(origin).Serialize());
 }
 
-TEST_F(CorsURLLoaderTest, 304ForSimpleRevalidation) {
+TEST_P(CorsURLLoaderTestWithSafeRevalidation, 304ForSimpleRevalidation) {
   const GURL origin("https://example.com");
   const GURL url("https://other.example.com/foo.png");
-  const GURL new_url("https://other2.example.com/bar.png");
 
   ResourceRequest request;
   request.mode = mojom::RequestMode::kCors;
@@ -1849,10 +1888,7 @@
   request.method = "GET";
   request.url = url;
   request.request_initiator = url::Origin::Create(origin);
-  request.headers.SetHeader("If-Modified-Since", "x");
-  request.headers.SetHeader("If-None-Match", "y");
-  request.headers.SetHeader("Cache-Control", "z");
-  request.is_revalidating = true;
+  SetRevalidationMetadata(request, "y", "x");
   CreateLoaderAndStart(request);
   RunUntilCreateLoaderAndStartCalled();
 
@@ -1924,10 +1960,9 @@
   EXPECT_EQ(net::ERR_FAILED, client().completion_status().error_code);
 }
 
-TEST_F(CorsURLLoaderTest, RevalidationAndPreflight) {
+TEST_P(CorsURLLoaderTestWithSafeRevalidation, RevalidationAndPreflight) {
   const GURL origin("https://example.com");
   const GURL url("https://other.example.com/foo.png");
-  const GURL new_url("https://other2.example.com/bar.png");
 
   ResourceRequest original_request;
   original_request.mode = mojom::RequestMode::kCors;
@@ -1935,11 +1970,8 @@
   original_request.method = "GET";
   original_request.url = url;
   original_request.request_initiator = url::Origin::Create(origin);
-  original_request.headers.SetHeader("If-Modified-Since", "x");
-  original_request.headers.SetHeader("If-None-Match", "y");
-  original_request.headers.SetHeader("Cache-Control", "z");
+  SetRevalidationMetadata(original_request, "y", "x");
   original_request.headers.SetHeader("foo", "bar");
-  original_request.is_revalidating = true;
   CreateLoaderAndStart(original_request);
   RunUntilCreateLoaderAndStartCalled();
 
@@ -1959,6 +1991,9 @@
   EXPECT_EQ(2, num_created_loaders());
   EXPECT_EQ(GetRequest().url, url);
   EXPECT_EQ(GetRequest().method, "GET");
+  EXPECT_EQ(GetRequest().headers.GetHeader("If-Modified-Since"), "x");
+  EXPECT_EQ(GetRequest().headers.GetHeader("If-None-Match"), "y");
+  EXPECT_EQ(GetRequest().headers.GetHeader("foo"), "bar");
 
   NotifyLoaderClientOnReceiveResponse(
       {{"Access-Control-Allow-Origin", "https://example.com"}});
@@ -3417,5 +3452,63 @@
   VerifyUpdateRequestForRedirect(303, "FOO");
 }
 
+TEST_F(CorsURLLoaderTest, SafeRevalidationIgnoresSpoofedIsRevalidating) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kSafeRevalidation);
+
+  const GURL origin("https://example.com");
+  const GURL url("https://other.example.com/secret");
+
+  ResourceRequest request;
+  request.mode = mojom::RequestMode::kCors;
+  request.credentials_mode = mojom::CredentialsMode::kOmit;
+  request.method = "GET";
+  request.url = url;
+  request.request_initiator = url::Origin::Create(origin);
+  request.is_revalidating = true;  // Spoofed flag without metadata!
+
+  CreateLoaderAndStart(request);
+  RunUntilCreateLoaderAndStartCalled();
+
+  // 304 response without Access-Control-Allow-Origin header
+  NotifyLoaderClientOnReceiveResponse(304, {});
+  NotifyLoaderClientOnComplete(net::OK);
+  RunUntilComplete();
+
+  EXPECT_TRUE(client().has_received_completion());
+  EXPECT_EQ(net::ERR_FAILED, client().completion_status().error_code);
+}
+
+TEST_F(CorsURLLoaderTest, SafeRevalidationWithStructuredMetadata) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(features::kSafeRevalidation);
+
+  const GURL origin("https://example.com");
+  const GURL url("https://other.example.com/secret");
+
+  ResourceRequest request;
+  request.mode = mojom::RequestMode::kCors;
+  request.credentials_mode = mojom::CredentialsMode::kOmit;
+  request.method = "GET";
+  request.url = url;
+  request.request_initiator = url::Origin::Create(origin);
+  request.revalidation_etag = "\"my-etag\"";
+
+  CreateLoaderAndStart(request);
+  RunUntilCreateLoaderAndStartCalled();
+
+  EXPECT_TRUE(IsNetworkLoaderStarted());
+  EXPECT_EQ(GetRequest().headers.GetHeader("If-None-Match"), "\"my-etag\"");
+
+  // 304 response without ACAO should pass when safe revalidation metadata is
+  // present
+  NotifyLoaderClientOnReceiveResponse(304, {});
+  NotifyLoaderClientOnComplete(net::OK);
+  RunUntilComplete();
+
+  EXPECT_TRUE(client().has_received_completion());
+  EXPECT_EQ(net::OK, client().completion_status().error_code);
+}
+
 }  // namespace
 }  // namespace network::cors
diff --git a/services/network/public/cpp/url_request_mojom_traits_unittest.cc b/services/network/public/cpp/url_request_mojom_traits_unittest.cc
index c3d76ae..41825ea 100644
--- a/services/network/public/cpp/url_request_mojom_traits_unittest.cc
+++ b/services/network/public/cpp/url_request_mojom_traits_unittest.cc
@@ -38,7 +38,9 @@
 namespace network {
 namespace {
 
-TEST(URLRequestMojomTraitsTest, Roundtrips_URLRequestReferrerPolicy) {
+using URLRequestMojomTraitsTest = testing::Test;
+
+TEST_F(URLRequestMojomTraitsTest, Roundtrips_URLRequestReferrerPolicy) {
   for (auto referrer_policy :
        {net::ReferrerPolicy::CLEAR_ON_TRANSITION_FROM_SECURE_TO_INSECURE,
         net::ReferrerPolicy::REDUCE_GRANULARITY_ON_TRANSITION_CROSS_ORIGIN,
@@ -59,7 +61,7 @@
   }
 }
 
-TEST(URLRequestMojomTraitsTest, Roundtrips_ResourceRequest) {
+TEST_F(URLRequestMojomTraitsTest, Roundtrips_ResourceRequest) {
   network::ResourceRequest original;
   original.method = "POST";
   original.url = GURL("https://example.com/resources/dummy.xml");
@@ -181,7 +183,61 @@
   EXPECT_TRUE(original.EqualsForTesting(copied));
 }
 
-TEST(URLRequestMojomTraitsTest, Roundtrips_TrustedParams) {
+TEST_F(URLRequestMojomTraitsTest,
+       Roundtrips_ResourceRequestWithRevalidationMetadata) {
+  network::ResourceRequest original;
+  original.url = GURL("https://example.com/");
+  original.revalidation_etag = "\"12345\"";
+  original.revalidation_last_modified = "Wed, 21 Oct 2015 07:28:00 GMT";
+
+  network::ResourceRequest copied;
+  EXPECT_TRUE(
+      mojo::test::SerializeAndDeserialize<mojom::URLRequest>(original, copied));
+  EXPECT_EQ(copied.revalidation_etag, "\"12345\"");
+  EXPECT_EQ(copied.revalidation_last_modified, "Wed, 21 Oct 2015 07:28:00 GMT");
+}
+
+TEST_F(URLRequestMojomTraitsTest, RevalidationMetadata_InvalidValues) {
+  {
+    // Empty etag should fail deserialization
+    network::ResourceRequest original;
+    original.url = GURL("https://example.com/");
+    original.revalidation_etag = "";
+    network::ResourceRequest copied;
+    EXPECT_FALSE(mojo::test::SerializeAndDeserialize<mojom::URLRequest>(
+        original, copied));
+  }
+  {
+    // Invalid header value (containing CRLF) in etag should fail
+    // deserialization
+    network::ResourceRequest original;
+    original.url = GURL("https://example.com/");
+    original.revalidation_etag = "invalid\r\nheader";
+    network::ResourceRequest copied;
+    EXPECT_FALSE(mojo::test::SerializeAndDeserialize<mojom::URLRequest>(
+        original, copied));
+  }
+  {
+    // Empty last_modified should fail deserialization
+    network::ResourceRequest original;
+    original.url = GURL("https://example.com/");
+    original.revalidation_last_modified = "";
+    network::ResourceRequest copied;
+    EXPECT_FALSE(mojo::test::SerializeAndDeserialize<mojom::URLRequest>(
+        original, copied));
+  }
+  {
+    // Invalid header value in last_modified should fail deserialization
+    network::ResourceRequest original;
+    original.url = GURL("https://example.com/");
+    original.revalidation_last_modified = "invalid\r\nheader";
+    network::ResourceRequest copied;
+    EXPECT_FALSE(mojo::test::SerializeAndDeserialize<mojom::URLRequest>(
+        original, copied));
+  }
+}
+
+TEST_F(URLRequestMojomTraitsTest, Roundtrips_TrustedParams) {
   network::ResourceRequest::TrustedParams original;
   original.disable_secure_dns = true;
   original.allow_cookies_from_browser = true;
@@ -227,7 +283,7 @@
   EXPECT_TRUE(copied.response_body_stream->pipe.is_valid());
 }
 
-TEST(URLRequestMojomTraitsTest, Roundtrips_TrustedParams_NullOpt) {
+TEST_F(URLRequestMojomTraitsTest, Roundtrips_TrustedParams_NullOpt) {
   network::ResourceRequest::TrustedParams original;
   original.enabled_client_hints = std::nullopt;
   network::ResourceRequest::TrustedParams copied;
Loading diff…

Original Bug Report

reported by [email protected]

CORS bypass and cross-origin data leak via spoofed is_revalidating flag

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.

Overview: A compromised renderer can bypass CORS checks by setting the internal is_revalidating flag to true on a resource request. This allows an attacker to send cache validation headers cross-origin without a preflight and bypass access checks on 304 Not Modified responses. Consequently, the attacker can leak cross-origin headers and create credentialed ETag/Last-Modified oracles.

Affected files:

  • services/network/cors/cors_url_loader.cc
  • services/network/cors/cors_url_loader_factory.cc
  • services/network/public/cpp/cors/cors_util.cc
  • services/network/public/mojom/url_request.mojom

Estimated timestamp from git blame: 2026-02-19

Description

There is a potential vulnerability in the Network Service’s handling of CORS checks for cache revalidation requests. The network::mojom::URLRequest struct contains a boolean flag is_revalidating. This flag is populated by the renderer and passed to the Network Service via Mojo IPC.

Currently, the Network Service implicitly trusts this renderer-provided flag to relax CORS enforcements:

  1. Preflight Bypass: In CorsUnsafeNotForbiddenRequestHeaderNames (services/network/cors/cors_util.cc), if is_revalidating is true, cache validation headers like If-Modified-Since and If-None-Match are exempted from triggering a CORS preflight.
  2. Access Check Bypass: In CorsURLLoader::OnReceiveResponse (services/network/cors/cors_url_loader.cc), if is_revalidating is true and the server responds with an HTTP 304 (Not Modified), the loader sets is_304_for_revalidation = true and skips the CheckAccess() function entirely.

Because a compromised renderer process can arbitrarily construct Mojo IPC messages, an attacker can spoof this flag on a cross-origin, credentialed request. By doing so, they can force the Network Service to skip both the preflight request and the response access checks for 304 responses, resulting in the raw URLResponseHead (including all cross-origin HTTP headers) being forwarded back to the compromised renderer. This acts as a cross-origin information leak and an ETag/Last-Modified oracle.

Potential Reproduction Steps

Note: These are suggested theoretical steps to trigger the vulnerability. Our tooling agent does not actively execute code or PoCs.

  1. Compromise a Renderer: An attacker achieves arbitrary code execution within a sandboxed renderer process (e.g., via a V8 bug).
  2. Construct a Malicious Request: Using the network::mojom::URLLoaderFactory IPC interface, the attacker creates a network::ResourceRequest targeting a sensitive cross-origin endpoint (e.g., https://victim.example/api/user-data).
  3. Configure CORS and Credentials: The attacker sets mode = kCors and credentials_mode = kInclude.
  4. Inject Cache Headers and Spoof Flag: The attacker adds an If-None-Match: "target-etag" header and sets the is_revalidating boolean flag to true.
  5. Initiate Request: The attacker sends the CreateLoaderAndStart IPC message.
  6. Network Service Bypasses Preflight: The Network Service receives the request. Due to the spoofed is_revalidating flag, it skips the preflight requirement for the If-None-Match header.
  7. Server Responds: The victim server receives the credentialed GET request. If the ETag matches, it returns an HTTP 304 Not Modified (which typically lacks Access-Control-Allow-Origin headers).
  8. Network Service Bypasses Access Check: In CorsURLLoader::OnReceiveResponse, the network service sees is_revalidating == true and response_code == 304. It skips the CheckAccess evaluation.
  9. Data Leak: The Network Service forwards the URLResponseHead containing the raw cross-origin headers back to the renderer, allowing the attacker to read them and confirm the ETag state.

Suggested Fix

The Network Service should not rely solely on the untrusted, renderer-provided is_revalidating flag to bypass critical CORS security checks.

Potential remediation approaches:

  • Enforce Server-Side Tracking: The Network Service (e.g., the HTTP Cache layer) should track whether a request is genuinely an internal cache revalidation, rather than accepting the state directly from the IPC struct. The renderer should not dictate this state.
  • Header Filtering: If the renderer must control this flag, CorsURLLoader should strip sensitive/non-safelisted cross-origin headers from the 304 response before forwarding the URLResponseHead back to the untrusted renderer when CheckAccess is skipped.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker