CVE-2026-87531
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fservices/network/cors/cors_url_loader_unittest.cc |
modified |
Files Changed
services/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_unittest.cc
Patch
From 0e2e868410f4043eefa8de364a5b5f092a3c513b Mon Sep 17 00:00:00 2001 From: Kenichi Ishibashi <[email protected]> Date: Thu, 30 Jul 2026 00:20:19 -0700 Subject: [PATCH] Clear was_cookie_in_request for non-basic responses URLResponseHead.was_cookie_in_request is derived from the request's cookie jar rather than the server response. CorsURLLoader was passing it through to the client unchanged for opaque and CORS-filtered responses, alongside the two sibling fields it already resets (device_bound_session_usage and did_use_server_http_auth). Reset the field to false whenever the response type is not kBasic, in OnReceiveResponse and both OnReceiveRedirect forwarding paths, matching the treatment of the sibling fields. Same-origin requests still surface the real value. Add CorsURLLoaderTest coverage mirroring the existing DidUseServerHttpAuth* cases. Note that this affects the following Blink UseCounter. - WebFeature::kUndeferrableThirdPartySubresourceRequestWithCookie Bug: 533597592 Change-Id: I15ba2c635618d1674aed2119e556b869b6d8c714 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8172883 Commit-Queue: Kenichi Ishibashi <[email protected]> Reviewed-by: Takashi Toyoshima <[email protected]> Cr-Commit-Position: refs/heads/main@{#1670874} --- diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc index 313c1cb..33b672a 100644 --- a/services/network/cors/cors_url_loader.cc +++ b/services/network/cors/cors_url_loader.cc @@ -689,6 +689,7 @@ response_head->device_bound_session_usage = mojom::DeviceBoundSessionUsage::kUnknown; response_head->did_use_server_http_auth = false; + response_head->was_cookie_in_request = false; } forwarding_client_->OnReceiveResponse( @@ -768,6 +769,7 @@ response_head->device_bound_session_usage = mojom::DeviceBoundSessionUsage::kUnknown; response_head->did_use_server_http_auth = false; + response_head->was_cookie_in_request = false; forwarding_client_->OnReceiveRedirect(censored_redirect_info, std::move(response_head)); return; @@ -847,6 +849,7 @@ response_head->device_bound_session_usage = mojom::DeviceBoundSessionUsage::kUnknown; response_head->did_use_server_http_auth = false; + response_head->was_cookie_in_request = false; } forwarding_client_->OnReceiveRedirect(redirect_info, std::move(response_head)); diff --git a/services/network/cors/cors_url_loader_unittest.cc b/services/network/cors/cors_url_loader_unittest.cc index 4032e4c..41414ce 100644 --- a/services/network/cors/cors_url_loader_unittest.cc +++ b/services/network/cors/cors_url_loader_unittest.cc @@ -703,6 +703,136 @@ EXPECT_FALSE(client().response_head()->did_use_server_http_auth); } +TEST_F(CorsURLLoaderTest, WasCookieInRequestSameOrigin) { + const GURL origin("https://example.com"); + const GURL url("https://example.com/foo.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 200 OK\nContent-Type: image/png\n"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveResponse(std::move(response)); + NotifyLoaderClientOnComplete(net::OK); + + RunUntilComplete(); + + ASSERT_TRUE(client().has_received_response()); + EXPECT_EQ(mojom::FetchResponseType::kBasic, + client().response_head()->response_type); + EXPECT_TRUE(client().response_head()->was_cookie_in_request); +} + +TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginNoCors) { + const GURL origin("https://example.com"); + const GURL url("https://other.example.com/foo.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 200 OK\nContent-Type: image/png\n"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveResponse(std::move(response)); + NotifyLoaderClientOnComplete(net::OK); + + RunUntilComplete(); + + ASSERT_TRUE(client().has_received_response()); + EXPECT_EQ(mojom::FetchResponseType::kOpaque, + client().response_head()->response_type); + EXPECT_FALSE(client().response_head()->was_cookie_in_request); +} + +TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginCors) { + const GURL origin("https://example.com"); + const GURL url("https://other.example.com/foo.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kCors); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 200 OK\nContent-Type: image/png\n"); + response->headers->SetHeader("Access-Control-Allow-Origin", + "https://example.com"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveResponse(std::move(response)); + NotifyLoaderClientOnComplete(net::OK); + + RunUntilComplete(); + + ASSERT_TRUE(client().has_received_response()); + EXPECT_EQ(mojom::FetchResponseType::kCors, + client().response_head()->response_type); + EXPECT_FALSE(client().response_head()->was_cookie_in_request); +} + +TEST_F(CorsURLLoaderTest, WasCookieInRequestSameOriginRedirect) { + const GURL origin("https://example.com"); + const GURL url("https://example.com/foo.png"); + const GURL new_url("https://example.com/bar.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 301 Moved Permanently\n"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url), + std::move(response)); + RunUntilRedirectReceived(); + + ASSERT_TRUE(client().has_received_redirect()); + EXPECT_EQ(mojom::FetchResponseType::kBasic, + client().response_head()->response_type); + EXPECT_TRUE(client().response_head()->was_cookie_in_request); +} + +TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginNoCorsRedirect) { + const GURL origin("https://example.com"); + const GURL url("https://other.example.com/foo.png"); + const GURL new_url("https://other.example.com/bar.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 301 Moved Permanently\n"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url), + std::move(response)); + RunUntilRedirectReceived(); + + ASSERT_TRUE(client().has_received_redirect()); + EXPECT_EQ(mojom::FetchResponseType::kOpaque, + client().response_head()->response_type); + EXPECT_FALSE(client().response_head()->was_cookie_in_request); +} + +TEST_F(CorsURLLoaderTest, WasCookieInRequestManualRedirect) { + const GURL origin("https://example.com"); + const GURL url("https://example.com/foo.png"); + const GURL new_url("https://example.com/bar.png"); + CreateLoaderAndStart(origin, url, mojom::RequestMode::kNavigate, + mojom::RedirectMode::kManual, + mojom::CredentialsMode::kInclude); + RunUntilCreateLoaderAndStartCalled(); + + auto response = mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 301 Moved Permanently\n"); + response->was_cookie_in_request = true; + NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url), + std::move(response)); + RunUntilRedirectReceived(); + + ASSERT_TRUE(client().has_received_redirect()); + EXPECT_EQ(mojom::FetchResponseType::kOpaqueRedirect, + client().response_head()->response_type); + EXPECT_FALSE(client().response_head()->was_cookie_in_request); +} + TEST_F(CorsURLLoaderTest, CrossOriginRequestFetchRequestWithCorsModeButMismatchedCorsHeader) { const GURL origin("https://example.com");
Regression Test / PoC
diff --git a/services/network/cors/cors_url_loader_unittest.cc b/services/network/cors/cors_url_loader_unittest.cc
index 4032e4c..41414ce 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -703,6 +703,136 @@
EXPECT_FALSE(client().response_head()->did_use_server_http_auth);
}
+TEST_F(CorsURLLoaderTest, WasCookieInRequestSameOrigin) {
+ const GURL origin("https://example.com");
+ const GURL url("https://example.com/foo.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 200 OK\nContent-Type: image/png\n");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveResponse(std::move(response));
+ NotifyLoaderClientOnComplete(net::OK);
+
+ RunUntilComplete();
+
+ ASSERT_TRUE(client().has_received_response());
+ EXPECT_EQ(mojom::FetchResponseType::kBasic,
+ client().response_head()->response_type);
+ EXPECT_TRUE(client().response_head()->was_cookie_in_request);
+}
+
+TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginNoCors) {
+ const GURL origin("https://example.com");
+ const GURL url("https://other.example.com/foo.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 200 OK\nContent-Type: image/png\n");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveResponse(std::move(response));
+ NotifyLoaderClientOnComplete(net::OK);
+
+ RunUntilComplete();
+
+ ASSERT_TRUE(client().has_received_response());
+ EXPECT_EQ(mojom::FetchResponseType::kOpaque,
+ client().response_head()->response_type);
+ EXPECT_FALSE(client().response_head()->was_cookie_in_request);
+}
+
+TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginCors) {
+ const GURL origin("https://example.com");
+ const GURL url("https://other.example.com/foo.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kCors);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 200 OK\nContent-Type: image/png\n");
+ response->headers->SetHeader("Access-Control-Allow-Origin",
+ "https://example.com");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveResponse(std::move(response));
+ NotifyLoaderClientOnComplete(net::OK);
+
+ RunUntilComplete();
+
+ ASSERT_TRUE(client().has_received_response());
+ EXPECT_EQ(mojom::FetchResponseType::kCors,
+ client().response_head()->response_type);
+ EXPECT_FALSE(client().response_head()->was_cookie_in_request);
+}
+
+TEST_F(CorsURLLoaderTest, WasCookieInRequestSameOriginRedirect) {
+ const GURL origin("https://example.com");
+ const GURL url("https://example.com/foo.png");
+ const GURL new_url("https://example.com/bar.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 301 Moved Permanently\n");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url),
+ std::move(response));
+ RunUntilRedirectReceived();
+
+ ASSERT_TRUE(client().has_received_redirect());
+ EXPECT_EQ(mojom::FetchResponseType::kBasic,
+ client().response_head()->response_type);
+ EXPECT_TRUE(client().response_head()->was_cookie_in_request);
+}
+
+TEST_F(CorsURLLoaderTest, WasCookieInRequestCrossOriginNoCorsRedirect) {
+ const GURL origin("https://example.com");
+ const GURL url("https://other.example.com/foo.png");
+ const GURL new_url("https://other.example.com/bar.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kNoCors);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 301 Moved Permanently\n");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url),
+ std::move(response));
+ RunUntilRedirectReceived();
+
+ ASSERT_TRUE(client().has_received_redirect());
+ EXPECT_EQ(mojom::FetchResponseType::kOpaque,
+ client().response_head()->response_type);
+ EXPECT_FALSE(client().response_head()->was_cookie_in_request);
+}
+
+TEST_F(CorsURLLoaderTest, WasCookieInRequestManualRedirect) {
+ const GURL origin("https://example.com");
+ const GURL url("https://example.com/foo.png");
+ const GURL new_url("https://example.com/bar.png");
+ CreateLoaderAndStart(origin, url, mojom::RequestMode::kNavigate,
+ mojom::RedirectMode::kManual,
+ mojom::CredentialsMode::kInclude);
+ RunUntilCreateLoaderAndStartCalled();
+
+ auto response = mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 301 Moved Permanently\n");
+ response->was_cookie_in_request = true;
+ NotifyLoaderClientOnReceiveRedirect(CreateRedirectInfo(301, "GET", new_url),
+ std::move(response));
+ RunUntilRedirectReceived();
+
+ ASSERT_TRUE(client().has_received_redirect());
+ EXPECT_EQ(mojom::FetchResponseType::kOpaqueRedirect,
+ client().response_head()->response_type);
+ EXPECT_FALSE(client().response_head()->was_cookie_in_request);
+}
+
TEST_F(CorsURLLoaderTest,
CrossOriginRequestFetchRequestWithCorsModeButMismatchedCorsHeader) {
const GURL origin("https://example.com");
Original Bug Report
Cross-origin cookie state leak via URLResponseHead.was_cookie_in_request
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The URLResponseHead.was_cookie_in_request field is derived from client-side cookie-jar state but is not cleared during cross-origin response sanitization in the network process. A compromised renderer can potentially read this field directly from the Mojo URLResponseHead struct for opaque cross-origin or ORB-blocked responses. This creates a potential 1-bit cross-origin oracle that leaks cookie existence and allows cookie path-prefix enumeration.
Affected files:
services/network/cors/cors_url_loader.ccservices/network/url_loader_util.ccservices/network/orb/orb_api.cc
Estimated timestamp from git blame: 2020-09-24
Description
When a network response is processed, url_loader_util::BuildResponseHead() populates the was_cookie_in_request field in URLResponseHead to indicate whether a cookie was sent with the corresponding request:
// services/network/url_loader_util.cc
response->was_cookie_in_request = std::ranges::any_of(
url_request.maybe_sent_cookies(),
[](const auto& cookie_with_access_result) {
return cookie_with_access_result.access_result.status.IsInclude();
});
Under Chrome’s post-compromise Site Isolation security model, a compromised renderer must not be able to obtain sensitive cross-origin data. To prevent cross-origin leaks, CorsURLLoader sanitizes certain client-side-state fields (such as did_use_server_http_auth and device_bound_session_usage) when forwarding non-kBasic (cross-origin) responses to the renderer:
// services/network/cors/cors_url_loader.cc
if (response_head->response_type != mojom::FetchResponseType::kBasic) {
response_head->device_bound_session_usage =
mojom::DeviceBoundSessionUsage::kUnknown;
response_head->did_use_server_http_auth = false;
}
However, was_cookie_in_request is not cleared by this block. The identical two-field-only sanitization also repeats on both redirect forwarding paths in cors_url_loader.cc (at kOpaqueRedirect early-returns and normal redirects). Furthermore, ORB’s blocked-response header strip (SanitizeBlockedResponseHeaders in services/network/orb/orb_api.cc) also leaves the field intact.
Consequently, even if a cross-origin response is opaque or blocked by ORB, the was_cookie_in_request bit is forwarded untouched to the renderer process over Mojo inside the URLResponseHead structure.
Potential Impact
A compromised renderer can potentially abuse this behavior to obtain a 1-bit oracle of whether a specific SameSite=None cookie exists in the user’s profile for any target cross-origin host. This leads to:
- Cross-origin visit/login-state disclosure: Verifying if an unpartitioned cookie exists for a specific site reveals if the user has previously authenticated to/visited that site.
- Cookie-
Pathprefix enumeration: Probing different resource paths allows the attacker to determine the restricted path of a cookie via path-matching results (disclosing sensitive site context).
This vulnerability is relevant under configurations where cross-site cookies are allowed (e.g., prior to full 3PCD rollout, under enterprise policies, per-site cookie exceptions, Related Website Sets, or Storage Access API grants).
Potential Step-by-Step Exploitation Scenario
Note: The following are suggested/potential steps, as our tooling agent does not currently have the ability to run code or construct a live proof-of-concept.
- A victim has previously logged into
https://victim.com, which set an unpartitionedSameSite=None; Secure; Path=/admincookie. - The user visits an attacker-controlled site,
https://evil.com, which runs in its own sandboxed renderer process but is assumed to be compromised (post-compromise threat model). - The compromised renderer constructs a cross-origin subresource request via Mojo targeting
https://victim.com/admin/probe.pngwithmode=kNoCorsandcredentials_mode=kInclude. - The network process validates and executes the request. Since credentials are included and third-party cookies are allowed for the pair, the unpartitioned cookie is attached.
url_request.maybe_sent_cookies()records this, andBuildResponseHead()setswas_cookie_in_request = true. - The response is returned. Since it is cross-origin,
CorsURLLoadermarks the response type askOpaqueand sanitizes the response head, but leaveswas_cookie_in_request == trueuntouched. - The compromised renderer intercepts the
OnReceiveResponseMojo message and directly inspectsURLResponseHead.was_cookie_in_request. Observingtruereveals the cookie’s existence. - The attacker repeats this fetch targeting
https://victim.com/other/probe.pngand observeswas_cookie_in_request == false, thereby learning both the login status and the specific cookie path restriction (Path=/admin).
Suggested Fix
Sanitize the was_cookie_in_request field in CorsURLLoader whenever the response is cross-origin. Specifically, clear it alongside did_use_server_http_auth inside services/network/cors/cors_url_loader.cc across all three sanitization paths:
if (response_head->response_type != mojom::FetchResponseType::kBasic) {
response_head->device_bound_session_usage =
mojom::DeviceBoundSessionUsage::kUnknown;
response_head->did_use_server_http_auth = false;
response_head->was_cookie_in_request = false; // Fix
}
Additionally, as a defense-in-depth measure, clear the field in ORB’s response sanitization in services/network/orb/orb_api.cc:
void SanitizeBlockedResponseHeaders(network::mojom::URLResponseHead& response) {
response.content_length = 0;
response.was_cookie_in_request = false; // Fix
if (response.headers)
RemoveAllHttpResponseHeaders(response.headers);
}
Evaluated with Chrome root at commit: b5b015ea5f690560237d1f0cff1405844cd12b8d
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.