CVE-2026-87449
Overview
Background
- DBSC (Device Bound Session Credentials)
- A Chrome mechanism that binds session cookies to a device key and periodically performs Discovery, Registration, and Refresh network fetches against server endpoints.
- `URLFetcher` (net::device_bound_sessions)
- A DBSC-internal fetcher that issues these requests directly, bypassing
network::URLLoaderand therefore the standard header decoration it normally applies. - Fetch-Metadata headers
- W3C request headers (
Sec-Fetch-Mode,Sec-Fetch-Dest,Sec-Fetch-Site) plusOriginthat let a server distinguish same-origin/same-site/cross-site and same-vs-cross-mode requests, the basis for CSRF/resource-isolation defenses. - `referring_origin_`
- The origin that configured
fetcher_endpoint_: the origin of the response carrying the registration header, or the scope origin of the session being refreshed.
Root Cause Analysis
DBSC Discovery, Registration, and Refresh requests are built by RegistrationFetcherImpl and dispatched through URLFetcher, which does not go through network::URLLoader; as the added comment states, network::URLLoader “would otherwise add them,” so these outgoing requests carried no Origin and no Sec-Fetch-Mode/Sec-Fetch-Dest/Sec-Fetch-Site assertions. A server receiving a DBSC request thus could not tell whether it was same-origin/same-site or cross-site, defeating standard Fetch-Metadata/CSRF protections on privileged session endpoints.
The patch threads a referring_origin_ through RegistrationFetcherParam and RegistrationRequestParam and adds SetupCommonFetchMetadata(), which stamps Sec-Fetch-Mode: no-cors, Sec-Fetch-Dest: empty, and a Sec-Fetch-Site value computed by SecFetchSiteForReferringOrigin(referring_origin_, request.url()), plus an explicit Origin: referring_origin_.Serialize() on the POST path. Per the commit message, URLFetcher::OnReceivedRedirect is additionally hardened to abort plaintext HTTP protocol-downgrade redirects with net::ERR_UNSAFE_REDIRECT and to recompute Sec-Fetch-Site at each redirect hop relative to referring_origin_ (that url_fetcher.cc change is listed as a changed file but its body is not shown in the provided diff).
The fix works because every DBSC pipeline now declares its origin/site relationship consistently, restoring the metadata a server needs to reject cross-site forgeries and preventing token/cookie exposure across a downgraded redirect.
network::URLLoader, they omitted the Origin/Fetch-Metadata assertions and unsafe-redirect handling that layer normally supplies; the fix centralizes header population (SetupCommonFetchMetadata) and per-hop Sec-Fetch-Site recomputation around a newly plumbed referring_origin_.Attack Path
- Reach a DBSC endpoint cross-site A cross-site context causes the browser to issue a DBSC Discovery/Registration/Refresh request to an attacker-relevant endpoint that relies on Fetch-Metadata to authorize state-changing operations.
- Requests arrive undecorated
Pre-patch,
RegistrationFetcherImplset only cookie/isolation fields and noOriginorSec-Fetch-*headers, so the endpoint saw a request indistinguishable from a legitimate same-site one, enabling cross-site request forgery against the session credential endpoints. - (Redirect leg) Force a protocol downgrade
Per the commit message, an HTTP 3xx redirect from
httpstohttpwas previously followed without validation, exposing DBSC cryptographic tokens and session cookies to plaintext (inferred, as theOnReceivedRedirectbody is not in the shown diff). - Metadata desynchronization across hops
Sec-Fetch-Sitewas not recomputed across same-site/cross-origin redirect boundaries, so a server’s origin-based checks could be evaded by routing through a redirect (inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
supported_algosnet/device_bound_sessions/registration_fetcher_param.h |
modified |
Files Changed
net/device_bound_sessions/registration_fetcher_param.ccnet/device_bound_sessions/session_binding_utils.hnet/device_bound_sessions/registration_fetcher_unittest.ccnet/device_bound_sessions/session_binding_utils.ccnet/device_bound_sessions/registration_request_param.ccnet/device_bound_sessions/url_fetcher.ccnet/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/url_fetcher.hnet/device_bound_sessions/url_fetcher_unittest.ccnet/device_bound_sessions/registration_fetcher_param.hnet/device_bound_sessions/registration_fetcher_param_unittest.ccnet/device_bound_sessions/registration_request_param.hnet/device_bound_sessions/session_binding_utils_unittest.cc
Audit Directions
- Fetchers that bypass `network::URLLoader`Enumerate direct
URLRequest/custom-fetcher call sites in//netand elsewhere and verify they explicitly stampOriginandSec-Fetch-*headers, since theURLLoaderlayer that normally adds them is skipped. - Redirect handling in custom fetchersAudit
OnReceivedRedirect/OnReceiveRedirectimplementations for missing HTTPS→HTTP downgrade rejection (net::ERR_UNSAFE_REDIRECT) and for failing to recompute origin/site-sensitive headers at each hop. - Origin baselines for credentialed session flowsReview session/credential features (DBSC-like) for whether the origin used to decorate requests is the true configuring origin vs. a derivable-but-spoofable value such as an opener or endpoint URL.
Patch
From 6dce8e75d9b25edc36726a8b58ac63b81bfe8a13 Mon Sep 17 00:00:00 2001 From: Ali Hijazi <[email protected]> Date: Sat, 15 Aug 2026 08:55:16 -0700 Subject: [PATCH] [DBSC] Enforce Fetch-Metadata and secure redirects Network fetches for Device Bound Session Credentials (DBSC) bypass the network::URLLoader, which ordinarily injects standard W3C Fetch-Metadata headers. Without explicit metadata decoration, outgoing Discovery, Registration, and Refresh requests lacked `Origin`, `Sec-Fetch-Mode`, `Sec-Fetch-Dest`, and `Sec-Fetch-Site` assertions. Furthermore, `URLFetcher`'s redirect handling previously allowed unvalidated HTTP 3xx protocol downgrades (exposing DBSC cryptographic tokens and session cookies to plaintext leakage) and failed to synchronize `Sec-Fetch-Site` metadata upon traversing same-site or cross-origin redirect boundaries. - Installs W3C Resource-Isolation headers (`no-cors`, `empty`, `Origin`, and `Sec-Fetch-Site`) across all Discovery and Registration/Refresh pipelines. - Implements rigorous redirect defenses in `URLFetcher::OnReceivedRedirect`: * Aborts plaintext (HTTP) protocol-downgrade transitions via `net::ERR_UNSAFE_REDIRECT`, neutralizing token-leakage risks. * Dynamically re-calculates and mutates `Sec-Fetch-Site` header values at each redirect hop relative to the baseline `referring_origin`. Bug: 501850947 Change-Id: I8a2ea35547d58567f9551e2557b61fcd2951cfef Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8139249 Reviewed-by: Adam Rice <[email protected]> Commit-Queue: Ali Hijazi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1680192} --- diff --git a/net/device_bound_sessions/registration_fetcher.cc b/net/device_bound_sessions/registration_fetcher.cc index efe2d49..1ad0ee2 100644 --- a/net/device_bound_sessions/registration_fetcher.cc +++ b/net/device_bound_sessions/registration_fetcher.cc @@ -16,6 +16,7 @@ #include "base/functional/callback_helpers.h" #include "base/functional/concurrent_closures.h" #include "base/metrics/histogram_functions.h" +#include "base/notreached.h" #include "base/rand_util.h" #include "base/task/sequenced_task_runner.h" #include "base/time/time.h" @@ -27,6 +28,7 @@ #include "net/base/features.h" #include "net/base/net_errors.h" #include "net/base/registry_controlled_domains/registry_controlled_domain.h" +#include "net/base/url_util.h" #include "net/device_bound_sessions/registration_request_param.h" #include "net/device_bound_sessions/session_binding_utils.h" #include "net/device_bound_sessions/session_challenge_param.h" @@ -35,6 +37,7 @@ #include "net/device_bound_sessions/session_key.h" #include "net/device_bound_sessions/session_params.h" #include "net/device_bound_sessions/url_fetcher.h" +#include "net/http/http_request_headers.h" #include "net/log/net_log_event_type.h" #include "net/traffic_annotation/network_traffic_annotation.h" #include "net/url_request/url_request_context.h" @@ -205,6 +208,7 @@ public: RegistrationFetcherImpl( const GURL& fetcher_endpoint, + url::Origin referring_origin, std::optional<std::string> session_identifier, SessionService& session_service, unexportable_keys::UnexportableKeyService& key_service, @@ -215,6 +219,7 @@ const std::optional<url::Origin>& original_request_initiator, unexportable_keys::BackgroundTaskPriority priority) : fetcher_endpoint_(fetcher_endpoint), + referring_origin_(std::move(referring_origin)), session_identifier_(std::move(session_identifier)), session_service_(session_service), key_service_(key_service), @@ -374,13 +379,9 @@ // TODO(crbug.com/495096658): Assert that `IsForRefreshRequest()` is false // once the tests are fixed. url_fetcher_ = std::make_unique<URLFetcher>( - context_, well_known_url, net_log_source_, IsForRefreshRequest()); - url_fetcher_->request().set_method("GET"); - url_fetcher_->request().set_disallow_credentials(); - url_fetcher_->request().set_site_for_cookies( - isolation_info_.site_for_cookies()); - url_fetcher_->request().set_initiator(original_request_initiator_); - url_fetcher_->request().set_isolation_info(isolation_info_); + context_, well_known_url, referring_origin_, net_log_source_, + IsForRefreshRequest()); + ConfigureWellKnownRequest(url_fetcher_->request()); url_fetcher_->Start(base::BindOnce( &RegistrationFetcherImpl::OnProviderWellKnownRequestComplete, GetWeakPtr(), request_params.TakeChallenge(), @@ -409,9 +410,40 @@ } private: + // Consolidates common URLRequest initialization, credential isolation, and + // W3C Resource-Isolation 'no-cors'/'empty' Fetch-Metadata parameters utilized + // uniformly across both Discovery (GET) and Registration (POST) DBSC + // operations. + // TODO(crbug.com/546625323): Stop duplicating request header and + // Fetch-Metadata population logic, and explore offloading header construction + // to `SessionService` or reusing `//services/network/sec_header_helpers.h`. + void SetupCommonFetchMetadata(URLRequest& request) { + request.set_site_for_cookies(site_for_cookies_); + request.set_initiator(original_request_initiator_); + request.set_isolation_info(isolation_info_); + + request.SetExtraRequestHeaderByName(kSecFetchModeHeaderName, "no-cors", + /*overwrite=*/true); + request.SetExtraRequestHeaderByName(kSecFetchDestHeaderName, "empty", + /*overwrite=*/true); + request.SetExtraRequestHeaderByName( + kSecFetchSiteHeaderName, + SecFetchSiteForReferringOrigin(referring_origin_, request.url()), + /*overwrite=*/true); + } + + // Configures and decorates an outbound `.well-known` DBSC discovery + // URLRequest via a side-effect-free, credentialless HTTP GET operation. + void ConfigureWellKnownRequest(URLRequest& request) { + request.set_method("GET"); + request.set_disallow_credentials(); + SetupCommonFetchMetadata(request); + } + void StartFetcherEndpointRequest() { url_fetcher_ = std::make_unique<URLFetcher>( - context_, fetcher_endpoint_, net_log_source_, IsForRefreshRequest()); + context_, fetcher_endpoint_, referring_origin_, net_log_source_, + IsForRefreshRequest()); ConfigureRequest(url_fetcher_->request()); if (last_registration_token_.has_value()) { url_fetcher_->request().SetExtraRequestHeaderByName( @@ -438,13 +470,9 @@ replacements.SetPathStr("/.well-known/device-bound-sessions"); GURL well_known_url = fetcher_endpoint_.ReplaceComponents(replacements); url_fetcher_ = std::make_unique<URLFetcher>( - context_, well_known_url, net_log_source_, IsForRefreshRequest()); - url_fetcher_->request().set_method("GET"); - url_fetcher_->request().set_disallow_credentials(); - url_fetcher_->request().set_site_for_cookies( - isolation_info_.site_for_cookies()); - url_fetcher_->request().set_initiator(original_request_initiator_); - url_fetcher_->request().set_isolation_info(isolation_info_); + context_, well_known_url, referring_origin_, net_log_source_, + IsForRefreshRequest()); + ConfigureWellKnownRequest(url_fetcher_->request()); url_fetcher_->Start(base::BindOnce( &RegistrationFetcherImpl::OnRelyingPartyWellKnownRequestComplete, GetWeakPtr(), std::move(challenge), std::move(authorization))); @@ -624,9 +652,17 @@ request.set_method("POST"); request.SetLoadFlags(LOAD_DISABLE_CACHE); - request.set_site_for_cookies(site_for_cookies_); - request.set_initiator(original_request_initiator_); - request.set_isolation_info(isolation_info_); + // Apply baseline W3C Fetch-Metadata ('no-cors', 'empty', 'Sec-Fetch-Site') + // and isolation boundaries utilized across all DBSC request pipelines. + SetupCommonFetchMetadata(request); + + // The endpoint may be a different (same-site) origin from the response or + // session that configured it, so attach `Origin` and Fetch Metadata + // headers reflecting that relationship. These requests don't go through + // `network::URLLoader`, which would otherwise add them. + request.SetExtraRequestHeaderByName(HttpRequestHeaders::kOrigin, + referring_origin_.Serialize(), + /*overwrite=*/true); if (IsForRefreshRequest()) { request.SetExtraRequestHeaderByName( @@ -808,13 +844,9 @@ GURL well_known_url = fetcher_endpoint_.ReplaceComponents(std::move(replacements)); url_fetcher_ = std::make_unique<URLFetcher>( - context_, well_known_url, net_log_source_, /*is_refresh=*/false); - url_fetcher_->request().set_method("GET"); - url_fetcher_->request().set_disallow_credentials(); - url_fetcher_->request().set_site_for_cookies( - isolation_info_.site_for_cookies()); - url_fetcher_->request().set_initiator(original_request_initiator_); - url_fetcher_->request().set_isolation_info(isolation_info_); + context_, well_known_url, referring_origin_, net_log_source_, + /*is_refresh=*/false); + ConfigureWellKnownRequest(url_fetcher_->request()); url_fetcher_->Start(base::BindOnce( &RegistrationFetcherImpl:: OnSubdomainRegistrationWellKnownRequestComplete, @@ -927,6 +959,10 @@ // Refers to the endpoint this class will use when triggering a registration // or refresh request. GURL fetcher_endpoint_; + // The origin that configured `fetcher_endpoint_`: the origin of the response + // carrying the registration header, or the scope origin of the session being + // refreshed. + url::Origin referring_origin_; // Populated iff this is a refresh request (not a registration request). std::optional<std::string> session_identifier_; const raw_ref<SessionService> session_service_; @@ -973,6 +1009,7 @@ unexportable_keys::BackgroundTaskPriority priority) { return std::make_unique<RegistrationFetcherImpl>( request_params.TakeRegistrationEndpoint(), + request_params.TakeReferringOrigin(), request_params.TakeSessionIdentifier(), session_service, key_service, context, isolation_info, site_for_cookies, net_log_source, original_request_initiator, priority); diff --git a/net/device_bound_sessions/registration_fetcher_param.cc b/net/device_bound_sessions/registration_fetcher_param.cc index b107613..7b7524f 100644 --- a/net/device_bound_sessions/registration_fetcher_param.cc +++ b/net/device_bound_sessions/registration_fetcher_param.cc @@ -57,6 +57,7 @@ RegistrationFetcherParam::RegistrationFetcherParam( GURL registration_endpoint, + url::Origin referring_origin, std::vector<crypto::SignatureVerifier::SignatureAlgorithm> supported_algos, std::optional<std::string> challenge, std::optional<std::string> authorization, @@ -65,6 +66,7 @@ std::optional<Session::Id> provider_session_id, AttestationMode attestation_mode) : registration_endpoint_(std::move(registration_endpoint)), + referring_origin_(std::move(referring_origin)), supported_algos_(std::move(supported_algos)), challenge_(std::move(challenge)), authorization_(std::move(authorization)), @@ -197,8 +199,9 @@ } return RegistrationFetcherParam( - std::move(registration_endpoint), std::move(supported_algos), - std::move(challenge), std::move(authorization), std::move(provider_key), + std::move(registration_endpoint), url::Origin::Create(request_url), + std::move(supported_algos), std::move(challenge), + std::move(authorization), std::move(provider_key), std::move(provider_url), std::move(provider_session_id), aik_required ? AttestationMode::kRequired : AttestationMode::kNone); } @@ -256,10 +259,15 @@ std::optional<std::string> provider_key, std::optional<GURL> provider_url, std::optional<Session::Id> provider_session_id, - AttestationMode attestation_mode) { + AttestationMode attestation_mode, + std::optional<url::Origin> maybe_referring_origin) { + url::Origin referring_origin = + maybe_referring_origin ? std::move(*maybe_referring_origin) + : url::Origin::Create(registration_endpoint); return RegistrationFetcherParam( - std::move(registration_endpoint), std::move(supported_algos), - std::move(challenge), std::move(authorization), std::move(provider_key), + std::move(registration_endpoint), std::move(referring_origin), + std::move(supported_algos), std::move(challenge), + std::move(authorization), std::move(provider_key), std::move(provider_url), std::move(provider_session_id), attestation_mode); } diff --git a/net/device_bound_sessions/registration_fetcher_param.h b/net/device_bound_sessions/registration_fetcher_param.h index e3cb3e0..e475059 100644 --- a/net/device_bound_sessions/registration_fetcher_param.h +++ b/net/device_bound_sessions/registration_fetcher_param.h @@ -16,6 +16,7 @@ #include "net/http/http_response_headers.h" #include "net/http/structured_headers.h" #include "url/gurl.h" +#include "url/origin.h" namespace net::device_bound_sessions { @@ -59,10 +60,16 @@ std::optional<std::string> provider_key = std::nullopt, std::optional<GURL> provider_url = std::nullopt, std::optional<Session::Id> provider_session_id = std::nullopt, - AttestationMode attestation_mode = AttestationMode::kNone); + AttestationMode attestation_mode = AttestationMode::kNone, + std::optional<url::Origin> maybe_referring_origin = std::nullopt); const GURL& registration_endpoint() const { return registration_endpoint_; } + // The origin of the response that supplied the registration header. May + // differ from the origin of `registration_endpoint()` when the header + // specified a different origin within the same site. + const url::Origin& referring_origin() const { return referring_origin_; } + base::span<const crypto::SignatureVerifier::SignatureAlgorithm> supported_algos() const { return supported_algos_; @@ -87,6 +94,7 @@ AttestationMode attestation_mode() const { return attestation_mode_; } GURL TakeRegistrationEndpoint() { return std::move(registration_endpoint_); } + url::Origin TakeReferringOrigin() { return std::move(referring_origin_); } std::optional<std::string> TakeChallenge() { return std::move(challenge_); } @@ -97,6 +105,7 @@ private: RegistrationFetcherParam( GURL registration_endpoint, + url::Origin referring_origin, std::vector<crypto::SignatureVerifier::SignatureAlgorithm> supported_algos, std::optional<std::string> challenge, @@ -111,6 +120,7 @@ const structured_headers::ParameterizedMember& session_registration); GURL registration_endpoint_; + url::Origin referring_origin_; std::vector<crypto::SignatureVerifier::SignatureAlgorithm> supported_algos_; std::optional<std::string> challenge_; std::optional<std::string> authorization_; diff --git a/net/device_bound_sessions/registration_request_param.cc b/net/device_bound_sessions/registration_request_param.cc index 204b9c5d..38c47f52 100644 --- a/net/device_bound_sessions/registration_request_param.cc +++ b/net/device_bound_sessions/registration_request_param.cc @@ -26,6 +26,7 @@ RegistrationFetcherParam&& fetcher_param) { return RegistrationRequestParam( fetcher_param.TakeRegistrationEndpoint(), + fetcher_param.TakeReferringOrigin(), /*session_identifier=*/std::nullopt, fetcher_param.TakeChallenge(), fetcher_param.TakeAuthorization(), fetcher_param.attestation_mode()); } @@ -34,7 +35,8 @@ RegistrationRequestParam RegistrationRequestParam::CreateForRefresh( const Session& session) { return RegistrationRequestParam( - session.refresh_url(), session.id().value(), session.cached_challenge(), + session.refresh_url(), session.origin(), session.id().value(), + session.cached_challenge(), /*authorization=*/std::nullopt, AttestationMode::kNone); } @@ -44,19 +46,26 @@ std::optional<std::string> session_identifier, std::optional<std::string> challenge, std::optional<std::string> authorization, - AttestationMode attestation_mode) { + AttestationMode attestation_mode, + std::optional<url::Origin> maybe_referring_origin) { + url::Origin referring_origin = + maybe_referring_origin ? std::move(*maybe_referring_origin) + : url::Origin::Create(registration_endpoint); return RegistrationRequestParam( - registration_endpoint, std::move(session_identifier), - std::move(challenge), std::move(authorization), attestation_mode); + registration_endpoint, std::move(referring_origin), + std::move(session_identifier), std::move(challenge), + std::move(authorization), attestation_mode); } RegistrationRequestParam::RegistrationRequestParam( const GURL& registration_endpoint, + url::Origin referring_origin, std::optional<std::string> session_identifier, std::optional<std::string> challenge, std::optional<std::string> authorization, AttestationMode attestation_mode) : registration_endpoint_(registration_endpoint), + referring_origin_(std::move(referring_origin)), session_identifier_(std::move(session_identifier)), challenge_(std::move(challenge)), authorization_(std::move(authorization)), diff --git a/net/device_bound_sessions/registration_request_param.h b/net/device_bound_sessions/registration_request_param.h index ad70912..42356d14 100644 --- a/net/device_bound_sessions/registration_request_param.h +++ b/net/device_bound_sessions/registration_request_param.h @@ -12,6 +12,7 @@ #include "net/base/net_export.h" #include "net/device_bound_sessions/session_params.h" #include "url/gurl.h" +#include "url/origin.h" namespace net::device_bound_sessions { @@ -33,6 +34,11 @@ RegistrationFetcherParam&& fetcher_param); static RegistrationRequestParam CreateForRefresh(const Session& session); + // The origin on whose behalf the registration or refresh request is being + // made: the origin whose response carried the registration header, or the + // origin scope of the session being refreshed. + const url::Origin& referring_origin() const { return referring_origin_; } + const std::optional<std::string>& challenge() const { return challenge_; } const std::optional<std::string>& authorization() const { return authorization_; @@ -40,6 +46,7 @@ AttestationMode attestation_mode() const { return attestation_mode_; } GURL TakeRegistrationEndpoint() { return std::move(registration_endpoint_); } + url::Origin TakeReferringOrigin() { return std::move(referring_origin_); } std::optional<std::string> TakeSessionIdentifier() { return std::move(session_identifier_); } @@ -53,16 +60,19 @@ std::optional<std::string> session_identifier, std::optional<std::string> challenge, ... (file diff truncated)
Regression Test / PoC
diff --git a/net/device_bound_sessions/registration_fetcher_param_unittest.cc b/net/device_bound_sessions/registration_fetcher_param_unittest.cc
index 5e21cb3..ddc297c 100644
--- a/net/device_bound_sessions/registration_fetcher_param_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_param_unittest.cc
@@ -64,6 +64,8 @@
const auto& param = params[0];
EXPECT_EQ(param.registration_endpoint(),
GURL("https://www.example.com/startsession"));
+ EXPECT_EQ(param.referring_origin(),
+ url::Origin::Create(registration_request));
EXPECT_THAT(param.supported_algos(),
UnorderedElementsAre(ECDSA_SHA256, RSA_PKCS1_SHA256));
EXPECT_EQ(param.challenge(), "c1");
@@ -471,6 +473,8 @@
const auto& param = params[0];
EXPECT_EQ(param.registration_endpoint(),
GURL("https://accounts.example.com/startsession"));
+ EXPECT_EQ(param.referring_origin(),
+ url::Origin::Create(registration_request));
EXPECT_THAT(param.supported_algos(),
UnorderedElementsAre(ECDSA_SHA256, RSA_PKCS1_SHA256));
EXPECT_EQ(param.challenge(), "c1");
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index f053118..314ad00f 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -72,11 +72,13 @@
using ::base::test::ValueIs;
using ::testing::_;
using ::testing::AllOf;
+using ::testing::Contains;
using ::testing::ElementsAre;
using ::testing::Eq;
using ::testing::Invoke;
using ::testing::Not;
using ::testing::Optional;
+using ::testing::Pair;
using ::testing::Property;
using ::testing::Return;
using ::testing::WithArg;
@@ -211,7 +213,6 @@
context_ = context_builder->Build();
}
-
unexportable_keys::UnexportableKeyService& unexportable_key_service() {
return unexportable_key_service_;
}
@@ -1941,6 +1942,123 @@
"auth_cookie", "Domain=.a.test; Path=/; Secure; SameSite=None")));
}
+std::unique_ptr<test_server::HttpResponse> CaptureFetchMetadataHeaders(
+ test_server::HttpRequest::HeaderMap* out_headers,
+ const test_server::HttpRequest& request) {
+ for (const char* name :
+ {"Origin", "Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-Dest"}) {
+ auto it = request.headers.find(name);
+ if (it != request.headers.end()) {
+ (*out_headers)[name] = it->second;
+ }
+ }
+ return ReturnResponse(HTTP_OK, kBasicValidJson, request);
+}
+
+TEST_F(RegistrationTest, RefreshSendsOriginAndFetchMetadata) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ test_server::HttpRequest::HeaderMap received_headers;
+ server_.RegisterRequestHandler(base::BindRepeating(
+ &CaptureFetchMetadataHeaders, base::Unretained(&received_headers)));
+ ASSERT_TRUE(server_.Start());
+
+ TestRegistrationCallback callback;
+ auto isolation_info = IsolationInfo::CreateTransient(/*nonce=*/std::nullopt);
+ auto request_param = RegistrationRequestParam::CreateForTesting(
+ GetBaseURL(), kSessionIdentifier, kChallenge,
+ /*authorization=*/std::nullopt);
+ UnexportableSigningKeyId key = CreateSigningKey();
+ std::unique_ptr<RegistrationFetcher> fetcher =
+ RegistrationFetcher::CreateFetcher(
+ request_param, session_service(),
+ std::ref(unexportable_key_service()), context_.get(),
+ std::ref(isolation_info), net::SiteForCookies(),
+ /*net_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartFetchWithExistingKey(request_param, std::move(key),
+ callback.callback());
+ callback.WaitForCall();
+ callback.outcome().SessionForTesting();
+
+ EXPECT_THAT(
+ received_headers,
+ Contains(Pair("Origin", url::Origin::Create(GetBaseURL()).Serialize())));
+ EXPECT_THAT(received_headers,
+ Contains(Pair("Sec-Fetch-Site", "same-origin")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Mode", "no-cors")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Dest", "empty")));
+}
+
+TEST_F(RegistrationTest, RefreshSendsSameSiteFetchMetadataForCrossOrigin) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ test_server::HttpRequest::HeaderMap received_headers;
+ server_.RegisterRequestHandler(base::BindRepeating(
+ &CaptureFetchMetadataHeaders, base::Unretained(&received_headers)));
+ ASSERT_TRUE(server_.Start());
+
+ // The session is scoped to a sibling origin within the same site as the
+ // refresh endpoint.
+ url::Origin scope_origin =
+ url::Origin::Create(server_.GetURL("other.a.test", "/"));
+
+ TestRegistrationCallback callback;
+ auto isolation_info = IsolationInfo::CreateTransient(/*nonce=*/std::nullopt);
+ auto request_param = RegistrationRequestParam::CreateForTesting(
+ GetBaseURL(), kSessionIdentifier, kChallenge,
+ /*authorization=*/std::nullopt, AttestationMode::kNone, scope_origin);
+ UnexportableSigningKeyId key = CreateSigningKey();
+ std::unique_ptr<RegistrationFetcher> fetcher =
+ RegistrationFetcher::CreateFetcher(
+ request_param, session_service(),
+ std::ref(unexportable_key_service()), context_.get(),
+ std::ref(isolation_info), net::SiteForCookies(),
+ /*net_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartFetchWithExistingKey(request_param, std::move(key),
+ callback.callback());
+ callback.WaitForCall();
+ callback.outcome().SessionForTesting();
+
+ EXPECT_THAT(received_headers,
+ Contains(Pair("Origin", scope_origin.Serialize())));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Site", "same-site")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Mode", "no-cors")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Dest", "empty")));
+}
+
+TEST_F(RegistrationTest, RegistrationSendsOriginAndFetchMetadata) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ test_server::HttpRequest::HeaderMap received_headers;
+ server_.RegisterRequestHandler(base::BindRepeating(
+ &CaptureFetchMetadataHeaders, base::Unretained(&received_headers)));
+ ASSERT_TRUE(server_.Start());
+
+ TestRegistrationCallback callback;
+ auto param = GetBasicParam();
+ std::unique_ptr<RegistrationFetcher> fetcher =
+ RegistrationFetcher::CreateFetcher(
+ param, session_service(), unexportable_key_service(), context_.get(),
+ IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
+ net::SiteForCookies(),
+ /*net_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+ callback.callback());
+ callback.WaitForCall();
+ callback.outcome().SessionForTesting();
+
+ EXPECT_THAT(
+ received_headers,
+ Contains(Pair("Origin", url::Origin::Create(GetBaseURL()).Serialize())));
+ EXPECT_THAT(received_headers,
+ Contains(Pair("Sec-Fetch-Site", "same-origin")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Mode", "no-cors")));
+ EXPECT_THAT(received_headers, Contains(Pair("Sec-Fetch-Dest", "empty")));
+}
+
TEST_F(RegistrationTest, FetchRegistrationAndChallengeRequired) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
server_.RegisterRequestHandler(base::BindRepeating(&ReturnForbidden));
@@ -3038,6 +3156,93 @@
SessionError::kSubdomainRegistrationWellKnownUnavailable);
}
+TEST_F(RegistrationTest, FederatedWellKnownDiscoverySendsFetchMetadata) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+
+ test_server::HttpRequest::HeaderMap captured_headers;
+ bool well_known_fetched = false;
+
+ // 1. Initial Endpoint Response: Instruct the Fetcher to redirect onto a
+ // Subdomain scope, implicitly triggering the DBSC cross-origin .well-known
+ // verification pipeline.
+ server_.RegisterRequestHandler(base::BindLambdaForTesting(
+ [&](const test_server::HttpRequest& request)
+ -> std::unique_ptr<test_server::HttpResponse> {
+ if (request.relative_url != "/") {
+ return nullptr;
+ }
+ auto response = std::make_unique<test_server::BasicHttpResponse>();
+ response->set_code(HTTP_FOUND);
+ response->AddCustomHeader(
+ "Location", server_.GetURL("subdomain.a.test", "/dbsc").spec());
+ return response;
+ }));
+
+ // 2. Mock Config Delivery for the Subdomain path.
+ server_.RegisterRequestHandler(base::BindLambdaForTesting(
+ [&](const test_server::HttpRequest& request)
+ -> std::unique_ptr<test_server::HttpResponse> {
+ if (request.relative_url != "/dbsc") {
+ return nullptr;
+ }
+ return ReturnResponse(HTTP_OK, kBasicValidJson, request);
+ }));
+
+ // 3. .well-known Interceptor: Capture the outbound GET metadata for the
+ // cross-origin validation check.
+ server_.RegisterRequestHandler(base::BindLambdaForTesting(
+ [&](const test_server::HttpRequest& request)
+ -> std::unique_ptr<test_server::HttpResponse> {
+ if (request.relative_url != "/.well-known/device-bound-sessions") {
+ return nullptr;
+ }
+
+ for (const char* name :
+ {"Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-Dest"}) {
+ auto it = request.headers.find(name);
+ if (it != request.headers.end()) {
+ captured_headers[name] = it->second;
+ }
+ }
+
+ well_known_fetched = true;
+ // Yield a 404 to cleanly isolate the test strictly to the pre-flight
+ // discovery dispatch.
+ return ReturnResponse(HTTP_NOT_FOUND, "", request);
+ }));
+
+ ASSERT_TRUE(server_.Start());
+
+ GURL registration_url = server_.GetURL("a.test", "/");
+ TestRegistrationCallback callback;
+
+ // Trigger Subdomain Cross-Origin Registration, implicitly firing the
+ // .well-known discovery step on the server-bound origin.
+ auto param = GetBasicParam(registration_url);
+ std::unique_ptr<RegistrationFetcher> fetcher =
+ RegistrationFetcher::CreateFetcher(
+ param, session_service(), unexportable_key_service(), context_.get(),
+ IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
+ net::SiteForCookies(),
+ /*net_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+ callback.callback());
+ callback.WaitForCall();
+
+ // 1. Assert the .well-known validator was indeed engaged by the DBSC
+ // state-machine.
+ EXPECT_TRUE(well_known_fetched);
+
+ // 2. Validate that our ConfigureWellKnownRequest pipeline successfully
+ // computed and appended W3C 'no-cors', 'empty', and Origin-relative Metadata.
+ EXPECT_THAT(captured_headers, Contains(Pair("Sec-Fetch-Mode", "no-cors")));
+ EXPECT_THAT(captured_headers, Contains(Pair("Sec-Fetch-Dest", "empty")));
+ EXPECT_THAT(captured_headers,
+ Contains(Pair("Sec-Fetch-Site", "same-origin")));
+}
+
TEST_F(RegistrationTest, FederatedSuccess) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
diff --git a/net/device_bound_sessions/session_binding_utils_unittest.cc b/net/device_bound_sessions/session_binding_utils_unittest.cc
index b2a982f..a88c193 100644
--- a/net/device_bound_sessions/session_binding_utils_unittest.cc
+++ b/net/device_bound_sessions/session_binding_utils_unittest.cc
@@ -22,6 +22,7 @@
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
+#include "url/origin.h"
namespace net::device_bound_sessions {
@@ -336,4 +337,37 @@
std::nullopt);
}
+TEST(SessionBindingUtilsTest, SecFetchSiteForReferringOrigin) {
+ // Validate that W3C Sec-Fetch-Site string-literal translation correctly
+ // resolves 'same-origin', 'same-site', and 'cross-site' origin relationships,
+ // including strictness against unencrypted/HTTP protocol-mismatches.
+ url::Origin referring_origin =
+ url::Origin::Create(GURL("https://www.example.com"));
+
+ // 1. Same-Origin: Matching cryptographic scheme, eTLD+1, and subdomain.
+ EXPECT_EQ(
+ SecFetchSiteForReferringOrigin(
+ referring_origin, GURL("https://www.example.com/path/to/resource")),
+ "same-origin");
+
+ // 2. Same-Site: Matching cryptographic scheme and eTLD+1, but differing
+ // subdomain boundaries.
+ EXPECT_EQ(
+ SecFetchSiteForReferringOrigin(
+ referring_origin, GURL("https://subdomain.example.com/endpoint")),
+ "same-site");
... (truncated)
Original Bug Report
CSRF Bypass via DBSC Refresh Request Missing Provenance Headers
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 Chrome Security team.
Overview: The Device Bound Session Credentials (DBSC) implementation constructs session refresh POST requests using a low-level net::URLRequest instead of network::URLLoader. This bypasses network service helpers, omitting standard provenance headers like Origin, Referer, and Sec-Fetch-*. When combined with cross-subdomain refresh configurations, this allows an attacker to trigger same-site, credentialed POST requests to sibling subdomains that bypass fail-open CSRF defenses.
Affected files:
net/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/session.ccnet/device_bound_sessions/url_fetcher.ccnet/device_bound_sessions/session_service_impl.cc
Estimated timestamp from git blame: 2026-03-05
Summary
A vulnerability exists in the Device Bound Session Credentials (DBSC) implementation where session refresh requests omit standard security provenance headers (Origin, Referer, Sec-Fetch-*). Because the refresh logic permits cross-subdomain configurations and reuses the triggering request’s same-site context, an attacker on a subdomain can trigger a credentialed POST request to a sibling subdomain. If the target application relies on header-based CSRF protections but fails open when headers are absent, it is vulnerable to Cross-Site Request Forgery (CSRF), even if it uses SameSite=Strict cookies.
Technical Details
- Cross-Subdomain Scope Validation:
Session::CreateIfValid(net/device_bound_sessions/session.cc) enforces that therefresh_urlandscope_originmust share the sameSchemefulSiteas the registering fetcher URL. This allows a session registered byattacker.example.comto specify a refresh URL onvictim.example.com. - Wildcard Initiator Permitted: The validation for
allowed_refresh_initiatorsusesIsValidHostPattern(net/device_bound_sessions/host_patterns.cc), which accepts the wildcard*, allowing any same-site origin to trigger the refresh. - Missing Cookies Trigger Refresh: If an attacker initiates a credentialed request (e.g., via
fetch()) to the victim subdomain and the bound cookie is missing from the cookie jar,SessionServiceImpl::ShouldDeferpauses the request and triggers a session refresh. - Context Reuse: When building the refresh request,
RegistrationFetcherImpl::ConfigureRequest(net/device_bound_sessions/registration_fetcher.cc) explicitly copies thesite_for_cookiesandinitiatorfrom the deferred request. This ensures the refresh request retains theSAME_SITE_STRICTcontext of the original request. - Low-Level Network Request:
RegistrationFetcherImpluses a customURLFetcherthat instantiates a rawnet::URLRequestdirectly, bypassing thenetwork::URLLoaderlayer. Consequently, the request entirely bypassesservices/network/sec_header_helpers.cc, omitting Fetch Metadata headers (Sec-Fetch-Site,Sec-Fetch-Mode, etc.). Similarly, because it skips Blink and the Network Service,OriginandRefererheaders are never attached. - Credential Attachment: As the request proceeds,
URLRequestHttpJob::AddCookieHeaderAndStartattaches cookies based on the inheritedSAME_SITE_STRICTcontext. This includes highly privilegedSameSite=Strictand__Host-prefixed cookies belonging to the victim subdomain.
Potential Impact
An attacker controlling a subdomain can forge a credentialed POST request to a sibling subdomain with an arbitrary path and query string. The request will include SameSite=Strict and __Host- cookies but lack Origin, Referer, and Sec-Fetch-* headers. Applications that rely on these headers to validate cross-origin requests, but fail open when they are missing, will incorrectly process the request as a safe, same-origin navigation, resulting in CSRF.
Suggested Reproduction Steps
Please note: These are suggested steps based on source code analysis. Our tooling agent does not currently run executable exploits.
- Ensure Chrome is running on an OS where DBSC is enabled (e.g., Windows).
- Host an attacker-controlled application on
https://attacker.example.com. - The attacker application returns a
Sec-Session-Registrationheader pointing to an endpoint that responds with a DBSC JSON configuration. - The JSON configuration specifies
"refresh_url": "https://victim.example.com/transfer?amount=1000"and"allowed_refresh_initiators": ["*"]. - Using JavaScript on
attacker.example.com, executefetch('https://victim.example.com/api', {credentials: 'include'}). - Because the bound cookie does not yet exist for
victim.example.com, Chrome defers the request and initiates a POST to therefresh_url. - Observe via a proxy that the resulting POST to
victim.example.com/transferincludesvictim.example.com’sSameSite=Strictcookies but completely lacksOrigin,Referer, andSec-Fetch-*headers.
Suggested Fix
Refactor RegistrationFetcherImpl and its associated URLFetcher to utilize the standard network::SimpleURLLoader (or network::URLLoader) instead of constructing a raw net::URLRequest. If utilizing the raw net::URLRequest is architecturally necessary, explicitly invoke network::SetFetchMetadataHeaders and manually append an appropriate Origin header (derived from the initiator) during request configuration in RegistrationFetcherImpl::ConfigureRequest.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.