CVE-2026-79074
Overview
Background
- Device Bound Session Credentials (DBSC)
- a mechanism that binds a site’s session to a device key. The network service tracks the user’s sessions per site and their URL scope.
- `device_bound_session_usage`
- a
URLResponseHeadfield of typemojom::DeviceBoundSessionUsage, such askInScopeRefreshNotYetNeededorkUnknown. According to the commit message, it is derived solely from the target URL’sSchemefulSiteand records whether the request fell inside a DBSC session’s scope. - `CorsURLLoader`
- the network-service loader that applies CORS and response tainting, then forwards the response head to the requesting client (usually a renderer) through
forwarding_client_. - Response type / tainting
mojom::FetchResponseTypevalueskBasic(same-origin),kCors,kOpaqueandkOpaqueRedirect. Non-basic responses are filtered, so cross-origin metadata must not reach the requester.
Root Cause Analysis
CorsURLLoader filled in CORS-related response-head fields such as response_type and timing_allow_passed before forwarding responses and redirects. It passed device_bound_session_usage through unchanged, even when the response was cross-origin (kOpaque, kCors, kOpaqueRedirect). That field describes the user’s DBSC state at the target site. For a cross-origin request it therefore exposed whether the user has a device-bound session at an unrelated site, and which paths fall inside that session’s scope, to the requesting origin’s process. This breaks the rule that filtered (non-basic) responses carry no cross-origin user state beyond what Fetch allows.
The fix resets the field to kUnknown in three places: in the response path whenever response_type != kBasic, in the manual-redirect path that produces kOpaqueRedirect, and in the general redirect-forwarding path whenever the tainted response_type is not kBasic. Same-origin responses keep the real value, as the DeviceBoundSessionUsageSameOrigin test checks.
kUnknown on every non-kBasic response and redirect that CorsURLLoader forwards.Attack Path
- Victim has a DBSC session
the user has a device-bound session at some site, for example
other.example.com. This requires DBSC to be enabled and in use by that site. - Cross-origin request
a page on an attacker origin issues a
no-corsorcorsrequest (an image load orfetch) to URLs on that site, as theDeviceBoundSessionUsageCrossOriginNoCorsandDeviceBoundSessionUsageCrossOriginCorstests do. - Field forwarded unfiltered
before the fix,
CorsURLLoaderforwardeddevice_bound_session_usagewith the real value, such askInScopeRefreshNotYetNeeded, to the attacker’s renderer on opaque/CORS responses and redirects. - State inference
by comparing the value across many URLs, the attacker learns whether a session exists and the shape of its scope. The patch shows only the data reaching the renderer. Whether script can read it without a compromised renderer, for example through use-counter or timing side effects, is
(inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/cors/cors_url_loader.cc |
modified | |
TEST_Fservices/network/cors/cors_url_loader_unittest.cc |
modified |
Files Changed
services/network/cors/cors_url_loader_test_util.hservices/network/cors/cors_url_loader.ccservices/network/cors/cors_url_loader_test_util.ccservices/network/cors/cors_url_loader_unittest.cc
Audit Directions
- New `URLResponseHead` fields vs. CORS filteringcheck every recently added response-head field that reflects user, session or cookie state (DBSC, storage access, privacy-sandbox signals) to see whether
CorsURLLoaderor Blink’s response filtering clears it forkOpaque,kCorsandkOpaqueRedirectresponses. - Redirect paths parityin loaders and throttles, check that sanitization applied in
OnReceiveResponseis repeated in everyOnReceiveRedirectbranch, including manual-redirect and censored-redirect paths. - Other cross-origin forwarding layersreview service-worker, prefetch, and navigation loaders that build or forward
URLResponseHeadwithout going throughCorsURLLoader, to confirm the same per-site state is scrubbed there too.
Patch
From fbce76f6c2ed4c50a12de9fa96f96f3a9140efc2 Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <bashi@chromium.org>
Date: Mon, 29 Jun 2026 16:52:03 -0700
Subject: [PATCH] [DBSC] Clear device_bound_session_usage for non-basic responses
URLResponseHead.device_bound_session_usage is derived solely from the
target URL's SchemefulSite. CorsURLLoader was passing it through to the
client unchanged for opaque and CORS-filtered responses, so a
cross-origin fetch could observe whether the user has a Device Bound
Session at an unrelated site and which paths fall inside its scope.
Reset the field to kUnknown whenever the response type is not kBasic,
both in OnReceiveResponse and in the two OnReceiveRedirect forwarding
paths. Same-origin and navigation requests still surface the real value,
so first-party use-counter coverage is preserved; DevTools reporting via
OnRawRequest is unaffected.
Add CorsURLLoaderTest coverage for the same-origin, opaque, and CORS
cases, plus a NotifyClientOnReceiveResponse overload that takes a
prebuilt URLResponseHead so tests can populate individual response-head
fields.
TAG=agy
CONV=ee22d996-4b11-4e9f-9a82-51d8d28ec963
Bug: 513745793
Change-Id: Iedb76f97ca1d512ce699176145fb7dfdcae362fa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8018442
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Kenichi Ishibashi <bashi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1654472}
---
diff --git a/services/network/cors/cors_url_loader.cc b/services/network/cors/cors_url_loader.cc
index 72fb956e..a1bce2a 100644
--- a/services/network/cors/cors_url_loader.cc
+++ b/services/network/cors/cors_url_loader.cc
@@ -44,6 +44,7 @@
#include "services/network/public/cpp/record_ontransfersizeupdate_utils.h"
#include "services/network/public/cpp/request_mode.h"
#include "services/network/public/cpp/timing_allow_origin_parser.h"
+#include "services/network/public/mojom/device_bound_sessions.mojom-shared.h"
#include "services/network/public/mojom/devtools_observer.mojom.h"
#include "services/network/public/mojom/early_hints.mojom.h"
#include "services/network/public/mojom/fetch_api.mojom.h"
@@ -703,6 +704,10 @@
response_head->timing_allow_passed = !timing_allow_failed_flag_;
response_head->has_authorization_covered_by_wildcard_on_preflight =
has_authorization_covered_by_wildcard_;
+ if (response_head->response_type != mojom::FetchResponseType::kBasic) {
+ response_head->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kUnknown;
+ }
forwarding_client_->OnReceiveResponse(
std::move(response_head), std::move(body), std::move(cached_metadata));
@@ -778,6 +783,8 @@
std::make_unique<GURL>(censored_redirect_info.new_url);
response_head->response_type = mojom::FetchResponseType::kOpaqueRedirect;
response_head->timing_allow_passed = !timing_allow_failed_flag_;
+ response_head->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kUnknown;
forwarding_client_->OnReceiveRedirect(censored_redirect_info,
std::move(response_head));
return;
@@ -853,6 +860,10 @@
response_head->response_type = response_tainting_;
}
response_head->timing_allow_passed = !timing_allow_failed_flag_;
+ if (response_head->response_type != mojom::FetchResponseType::kBasic) {
+ response_head->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kUnknown;
+ }
forwarding_client_->OnReceiveRedirect(redirect_info,
std::move(response_head));
}
diff --git a/services/network/cors/cors_url_loader_test_util.cc b/services/network/cors/cors_url_loader_test_util.cc
index f52c4fb6..4d548cb8 100644
--- a/services/network/cors/cors_url_loader_test_util.cc
+++ b/services/network/cors/cors_url_loader_test_util.cc
@@ -79,6 +79,14 @@
std::nullopt);
}
+void TestURLLoaderFactory::NotifyClientOnReceiveResponse(
+ mojom::URLResponseHeadPtr response_head,
+ mojo::ScopedDataPipeConsumerHandle body) {
+ DCHECK(client_remote_);
+ client_remote_->OnReceiveResponse(std::move(response_head), std::move(body),
+ std::nullopt);
+}
+
void TestURLLoaderFactory::NotifyClientOnComplete(int error_code) {
DCHECK(client_remote_);
client_remote_->OnComplete(URLLoaderCompletionStatus(error_code));
diff --git a/services/network/cors/cors_url_loader_test_util.h b/services/network/cors/cors_url_loader_test_util.h
index e135228b..7de0082a 100644
--- a/services/network/cors/cors_url_loader_test_util.h
+++ b/services/network/cors/cors_url_loader_test_util.h
@@ -31,6 +31,7 @@
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/url_loader.mojom.h"
#include "services/network/public/mojom/url_loader_factory.mojom.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
#include "services/network/resource_scheduler/resource_scheduler.h"
#include "services/network/test/test_url_loader_network_observer.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -80,6 +81,9 @@
const std::vector<std::pair<std::string, std::string>>& extra_headers,
mojo::ScopedDataPipeConsumerHandle body);
+ void NotifyClientOnReceiveResponse(mojom::URLResponseHeadPtr response_head,
+ mojo::ScopedDataPipeConsumerHandle body);
+
void NotifyClientOnComplete(int error_code);
void NotifyClientOnComplete(const CorsErrorStatus& status);
@@ -204,6 +208,15 @@
status_code, extra_headers, std::move(body));
}
+ void NotifyLoaderClientOnReceiveResponse(
+ mojom::URLResponseHeadPtr response_head,
+ mojo::ScopedDataPipeConsumerHandle body =
+ mojo::ScopedDataPipeConsumerHandle()) {
+ DCHECK(test_url_loader_factory_);
+ test_url_loader_factory_->NotifyClientOnReceiveResponse(
+ std::move(response_head), std::move(body));
+ }
+
void NotifyLoaderClientOnReceiveRedirect(
const net::RedirectInfo& redirect_info,
const std::vector<std::pair<std::string, std::string>>& extra_headers =
diff --git a/services/network/cors/cors_url_loader_unittest.cc b/services/network/cors/cors_url_loader_unittest.cc
index e007dfe5..a2e36c9 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -19,6 +19,7 @@
#include "net/cookies/cookie_util.h"
#include "net/cookies/site_for_cookies.h"
#include "net/http/http_request_headers.h"
+#include "net/http/http_response_headers.h"
#include "net/log/test_net_log_util.h"
#include "net/storage_access_api/status.h"
#include "net/test/gtest_util.h"
@@ -29,6 +30,7 @@
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/mojom/cors.mojom.h"
+#include "services/network/public/mojom/device_bound_sessions.mojom-shared.h"
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/url_request.mojom-forward.h"
#include "services/network/test/mock_devtools_observer.h"
@@ -500,6 +502,77 @@
EXPECT_EQ(net::OK, client().completion_status().error_code);
}
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageSameOrigin) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded,
+ client().response_head()->device_bound_session_usage);
+}
+
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageCrossOriginNoCors) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kUnknown,
+ client().response_head()->device_bound_session_usage);
+}
+
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageCrossOriginCors) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kUnknown,
+ client().response_head()->device_bound_session_usage);
+}
+
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 e007dfe5..a2e36c9 100644
--- a/services/network/cors/cors_url_loader_unittest.cc
+++ b/services/network/cors/cors_url_loader_unittest.cc
@@ -19,6 +19,7 @@
#include "net/cookies/cookie_util.h"
#include "net/cookies/site_for_cookies.h"
#include "net/http/http_request_headers.h"
+#include "net/http/http_response_headers.h"
#include "net/log/test_net_log_util.h"
#include "net/storage_access_api/status.h"
#include "net/test/gtest_util.h"
@@ -29,6 +30,7 @@
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/mojom/cors.mojom.h"
+#include "services/network/public/mojom/device_bound_sessions.mojom-shared.h"
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/url_request.mojom-forward.h"
#include "services/network/test/mock_devtools_observer.h"
@@ -500,6 +502,77 @@
EXPECT_EQ(net::OK, client().completion_status().error_code);
}
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageSameOrigin) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded,
+ client().response_head()->device_bound_session_usage);
+}
+
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageCrossOriginNoCors) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kUnknown,
+ client().response_head()->device_bound_session_usage);
+}
+
+TEST_F(CorsURLLoaderTest, DeviceBoundSessionUsageCrossOriginCors) {
+ 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->device_bound_session_usage =
+ mojom::DeviceBoundSessionUsage::kInScopeRefreshNotYetNeeded;
+ 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_EQ(mojom::DeviceBoundSessionUsage::kUnknown,
+ client().response_head()->device_bound_session_usage);
+}
+
TEST_F(CorsURLLoaderTest,
CrossOriginRequestFetchRequestWithCorsModeButMismatchedCorsHeader) {
const GURL origin("https://example.com");
Original Bug Report
Cross-origin login state leak via DBSC session usage in URLResponseHead
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 device_bound_session_usage field in the Network Service’s response metadata potentially leaks the presence and scope of Device Bound Session Credentials (DBSC) for any site. A compromised renderer can use this as a cross-origin login oracle to determine if a user is signed into a specific site, bypassing Site Isolation and third-party cookie blocking.
Affected files:
services/network/url_loader_util.ccnet/device_bound_sessions/session_service_impl.ccnet/device_bound_sessions/session.ccnet/url_request/url_request_http_job.ccservices/network/public/mojom/url_response_head.mojomservices/network/cors/cors_url_loader.ccservices/network/orb/orb_api.cc
Estimated timestamp from git blame: 2025-04-16
Technical Description
A potential information leak exists in the implementation of Device Bound Session Credentials (DBSC) within the Chromium Network Service. The URLResponseHead.device_bound_session_usage field is populated with the DBSC status of a request and delivered to the renderer process without adequate origin checks or sanitization during security policy enforcement (CORS/ORB).
Root Cause
In services/network/url_loader_util.cc, the function BuildResponseHead() unconditionally populates the device_bound_session_usage field based on the state recorded on the net::URLRequest during the DBSC check path:
// services/network/url_loader_util.cc
response->device_bound_session_usage =
static_cast<network::mojom::DeviceBoundSessionUsage>(
net::device_bound_sessions::GetMaxUsage(
url_request.device_bound_session_usage()));
The usage state is determined in net/device_bound_sessions/session_service_impl.cc and net/device_bound_sessions/session.cc. The logic evaluates whether a DBSC session exists for the target origin’s site, whether the specific request path is in scope, and whether the request was deferred (e.g., due to missing bound cookies).
Critically, Opaque Response Blocking (ORB) and CORS do not sanitize this field. In services/network/orb/orb_api.cc, the SanitizeBlockedResponseHeaders function strips standard HTTP headers and zeros the content length but leaves device_bound_session_usage intact. This allows the field to reach a compromised renderer even for cross-origin requests where the body and headers are blocked.
Potential Exploitation Steps
Note: These steps are based on source code analysis and have not been validated with a functional proof of concept.
- An attacker achieves code execution in a sandboxed renderer process (e.g., via a memory corruption bug).
- The compromised renderer issues a
ResourceRequestto a victim origin (e.g.,https://accounts.google.com/) withmode=kNoCorsandcredentials_mode=kIncludevia theURLLoaderFactoryMojo interface. - The Network Service processes the request. The DBSC logic updates the request’s internal session usage map based on existing sessions for that site.
- Upon receiving the server response, the Network Service populates
URLResponseHead.device_bound_session_usage. If a DBSC session exists for the victim origin, the value will be non-zero (e.g.,kSiteMatchNotInScope,kInScope*, orkDeferred). - The response metadata is delivered to the renderer’s
URLLoaderClient::OnReceiveResponsecallback. - The renderer inspects the field. A value > 1 serves as a high-fidelity signal that the user is currently logged into the target origin via DBSC. By probing different paths, the attacker can further deduce the session’s scope rules.
Impact
This issue constitutes a cross-origin information leak that allows a compromised renderer to determine user login status for unrelated origins. It bypasses Site Isolation’s data confidentiality guarantees and functions even when third-party cookies are blocked, as DBSC checks occur independently of standard cookie inclusion rules. The DBSC feature is currently enabled by default on Windows.
Suggested Fix
The device_bound_session_usage field should be sanitized or cleared in network::orb::SanitizeBlockedResponseHeaders and potentially in network::cors::CorsURLLoader::OnReceiveResponse if the response is opaque or if the initiator is not same-origin with the target. Metadata describing the internal DBSC state of a cross-origin request should not be exposed to the renderer unless it is same-origin with the target.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.