CVE-2026-87485
Overview
Background
- CORS access check
- the
CheckAccessstep inCorsURLLoaderthat verifies a cross-origin response carries a validAccess-Control-Allow-Originheader before the response is exposed to the initiating renderer. - Revalidation / `is_revalidating`
- a
ResourceRequestboolean that marks a conditional cache-revalidation request (one carryingIf-None-Match/If-Modified-Since), used by CORS logic to special-case those requests and their304 Not Modifiedresponses. - 304 revalidation exemption
- the rule that a
304reply 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.
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
- Craft a revalidation-flagged cross-origin request
A malicious or compromised renderer issues a cross-origin request via
ResourceRequestwithrequest.is_revalidating = trueset, but without any legitimate revalidation metadata (as exercised bySafeRevalidationIgnoresSpoofedIsRevalidating, which setsis_revalidating = truewith the comment// Spoofed flag without metadata!). - Induce or receive a 304 response
The target cross-origin server (or an intermediary) returns a
304 Not Modifiedresponse that lacks anAccess-Control-Allow-Originheader, soresponse_head->headers->response_code() == 304holds. - Trigger the exemption to skip `CheckAccess`
Pre-patch,
is_304_for_revalidationis true becauserequest_.is_revalidatingis true, sofetch_cors_flag_ && !is_304_for_revalidationis false andCheckAccessis never called, delivering the cross-origin response to the renderer without CORS authorization instead of failing withnet::ERR_FAILED. - 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
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
| Function | Change | Notes |
|---|---|---|
ifservices/network/cors/cors_url_loader.cc |
modified |
Files Changed
third_party/blink/renderer/platform/loader/fetch/resource_request.hservices/network/public/cpp/features.htools/metrics/histograms/metadata/network/enums.xmlservices/network/public/cpp/url_request_mojom_traits.hthird_party/blink/renderer/platform/loader/fetch/resource_fetcher.ccservices/network/cors/cors_url_loader_unittest.ccservices/network/public/cpp/resource_request.hservices/network/prefetch_matches.ccservices/network/cors/cors_url_loader.ccservices/network/public/cpp/url_request_mojom_traits_unittest.ccthird_party/blink/renderer/platform/loader/fetch/url_loader/request_conversion.ccservices/network/public/cpp/url_request_mojom_traits.ccservices/network/public/mojom/url_request.mojomservices/network/cors/preflight_controller.ccservices/network/public/cpp/features.ccservices/network/public/cpp/resource_request.cc
Audit Directions
- Renderer-self-reported flags gating security decisionsAudit every place the network/CORS layer reads a Blink-set boolean (like
is_revalidating, ortrusted_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 codesReview other branches that skip
CheckAccessor 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 boundaryCheck that new
ResourceRequestfields carrying header-derived values are validated in theStructTraitsReadpath (non-empty,net::HttpUtil::IsValidHeaderValue) before use, mirroring therevalidation_etag/revalidation_last_modifiedvalidation, to prevent header injection or spoofed-metadata bypasses.
Patch
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)
Regression Test / PoC
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;
Original Bug Report
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.ccservices/network/cors/cors_url_loader_factory.ccservices/network/public/cpp/cors/cors_util.ccservices/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:
- Preflight Bypass: In
CorsUnsafeNotForbiddenRequestHeaderNames(services/network/cors/cors_util.cc), ifis_revalidatingis true, cache validation headers likeIf-Modified-SinceandIf-None-Matchare exempted from triggering a CORS preflight. - Access Check Bypass: In
CorsURLLoader::OnReceiveResponse(services/network/cors/cors_url_loader.cc), ifis_revalidatingis true and the server responds with an HTTP 304 (Not Modified), the loader setsis_304_for_revalidation = trueand skips theCheckAccess()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.
- Compromise a Renderer: An attacker achieves arbitrary code execution within a sandboxed renderer process (e.g., via a V8 bug).
- Construct a Malicious Request: Using the
network::mojom::URLLoaderFactoryIPC interface, the attacker creates anetwork::ResourceRequesttargeting a sensitive cross-origin endpoint (e.g.,https://victim.example/api/user-data). - Configure CORS and Credentials: The attacker sets
mode = kCorsandcredentials_mode = kInclude. - Inject Cache Headers and Spoof Flag: The attacker adds an
If-None-Match: "target-etag"header and sets theis_revalidatingboolean flag totrue. - Initiate Request: The attacker sends the
CreateLoaderAndStartIPC message. - Network Service Bypasses Preflight: The Network Service receives the request. Due to the spoofed
is_revalidatingflag, it skips the preflight requirement for theIf-None-Matchheader. - Server Responds: The victim server receives the credentialed GET request. If the ETag matches, it returns an HTTP
304 Not Modified(which typically lacksAccess-Control-Allow-Originheaders). - Network Service Bypasses Access Check: In
CorsURLLoader::OnReceiveResponse, the network service seesis_revalidating == trueandresponse_code == 304. It skips theCheckAccessevaluation. - Data Leak: The Network Service forwards the
URLResponseHeadcontaining 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,
CorsURLLoadershould strip sensitive/non-safelisted cross-origin headers from the304response before forwarding theURLResponseHeadback to the untrusted renderer whenCheckAccessis 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.