CVE-2026-13021
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
fornet/device_bound_sessions/registration_fetcher.cc |
modified | |
TEST_Fnet/device_bound_sessions/registration_fetcher_unittest.cc |
modified | |
ifnet/device_bound_sessions/registration_fetcher_unittest.cc |
modified |
Files Changed
net/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/registration_fetcher_unittest.cc
Patch
From 159631e99b1d8828210daff47f6afe5a67a0fca5 Mon Sep 17 00:00:00 2001
From: Jan Wilken Dörrie <jdoerrie@chromium.org>
Date: Wed, 20 May 2026 08:51:21 -0700
Subject: [PATCH] [dbsc] Use final URL for registration validation checks
This change updates the registration fetcher to use the final URL after
redirects for registration validation checks.
Bug: 511776603
Change-Id: Ibacb0c22deddd9a1b6104690a70d7dd06a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7852498
Commit-Queue: Jan Wilken Dörrie <jdoerrie@chromium.org>
Reviewed-by: Alex Ilin <alexilin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1633609}
---
diff --git a/net/device_bound_sessions/registration_fetcher.cc b/net/device_bound_sessions/registration_fetcher.cc
index 214e2b2f..61a16ebf 100644
--- a/net/device_bound_sessions/registration_fetcher.cc
+++ b/net/device_bound_sessions/registration_fetcher.cc
@@ -415,6 +415,8 @@
return SessionError::kSessionProviderWellKnownHasProviderOrigin;
}
+ // TODO(crbug.com/511776603): Evaluate whether to use the final redirect URL
+ // instead of the original URL here in a follow-up.
std::string target_origin =
url::Origin::Create(fetcher_endpoint_).Serialize();
if (!maybe_params->relying_origins.has_value() ||
@@ -470,6 +472,8 @@
return SessionError::kRelyingPartyWellKnownHasRelyingOrigins;
}
+ // TODO(crbug.com/511776603): Evaluate whether to use the final redirect URL
+ // instead of the original URL here in a follow-up.
if (!maybe_params->provider_origin.has_value() ||
url::Origin::Create(provider_url_).Serialize() !=
*maybe_params->provider_origin) {
@@ -687,11 +691,15 @@
}
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 =
device_bound_sessions::SessionChallengeParam::CreateIfValid(
- fetcher_endpoint_, headers);
+ final_registration_url, headers);
for (const SessionChallengeParam& challenge_param : challenge_params) {
if (challenge_param.session_id() == *session->id()) {
session->set_cached_challenge(challenge_param.challenge());
@@ -728,7 +736,7 @@
// 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).
- fetcher_endpoint_.GetHost() != session->origin().host()) {
+ final_registration_url.host() != session->origin().host()) {
GURL::Replacements replacements;
replacements.SetPathStr("/.well-known/device-bound-sessions");
replacements.SetHostStr(session->origin().host());
@@ -742,10 +750,10 @@
isolation_info_.site_for_cookies());
url_fetcher_->request().set_initiator(original_request_initiator_);
url_fetcher_->request().set_isolation_info(isolation_info_);
- url_fetcher_->Start(
- base::BindOnce(&RegistrationFetcherImpl::
- OnSubdomainRegistrationWellKnownRequestComplete,
- GetWeakPtr(), std::move(session)));
+ url_fetcher_->Start(base::BindOnce(
+ &RegistrationFetcherImpl::
+ OnSubdomainRegistrationWellKnownRequestComplete,
+ GetWeakPtr(), std::move(final_registration_url), std::move(session)));
return;
}
@@ -754,13 +762,15 @@
}
void OnSubdomainRegistrationWellKnownRequestComplete(
+ GURL final_registration_url,
std::unique_ptr<Session> session) {
RunCallback(OnSubdomainRegistrationWellKnownRequestCompleteInternal(
- std::move(session)));
+ std::move(final_registration_url), std::move(session)));
// `this` may be deleted.
}
RegistrationResult OnSubdomainRegistrationWellKnownRequestCompleteInternal(
+ GURL final_registration_url,
std::unique_ptr<Session> session) {
HttpResponseHeaders* headers = url_fetcher_->request().response_headers();
const int response_code = headers ? headers->response_code() : 0;
@@ -788,7 +798,7 @@
if (!maybe_params->registering_origins.has_value() ||
!std::ranges::contains(
*maybe_params->registering_origins,
- url::Origin::Create(fetcher_endpoint_).Serialize())) {
+ url::Origin::Create(final_registration_url).Serialize())) {
return CreateErrorRegistrationResult(
SessionError(SessionError::kSubdomainRegistrationUnauthorized));
}
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index 41f4782..97c8355a 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -15,6 +15,7 @@
#include "base/json/json_writer.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
+#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
@@ -2709,6 +2710,69 @@
}
}
+TEST_F(RegistrationTest, RegistrationRedirectToSubdomain) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ bool well_known_fetched = false;
+
+ // 1. Redirect request
+ 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. Return config
+ 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. Monitor well-known requests
+ 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;
+ }
+ well_known_fetched = true;
+ return ReturnResponse(HTTP_NOT_FOUND, "", request);
+ }));
+
+ ASSERT_TRUE(server_.Start());
+
+ GURL registration_url = server_.GetURL("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();
+
+ // Verify well-known check is triggered and registration fails.
+ EXPECT_TRUE(well_known_fetched);
+ EXPECT_EQ(callback.outcome().SessionErrorForTesting()->type,
+ SessionError::kSubdomainRegistrationWellKnownUnavailable);
+}
+
TEST_F(RegistrationTest, FederatedSuccess) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
Regression Test / PoC
diff --git a/net/device_bound_sessions/registration_fetcher_unittest.cc b/net/device_bound_sessions/registration_fetcher_unittest.cc
index 41f4782..97c8355a 100644
--- a/net/device_bound_sessions/registration_fetcher_unittest.cc
+++ b/net/device_bound_sessions/registration_fetcher_unittest.cc
@@ -15,6 +15,7 @@
#include "base/json/json_writer.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
+#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
@@ -2709,6 +2710,69 @@
}
}
+TEST_F(RegistrationTest, RegistrationRedirectToSubdomain) {
+ crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
+ bool well_known_fetched = false;
+
+ // 1. Redirect request
+ 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. Return config
+ 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. Monitor well-known requests
+ 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;
+ }
+ well_known_fetched = true;
+ return ReturnResponse(HTTP_NOT_FOUND, "", request);
+ }));
+
+ ASSERT_TRUE(server_.Start());
+
+ GURL registration_url = server_.GetURL("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();
+
+ // Verify well-known check is triggered and registration fails.
+ EXPECT_TRUE(well_known_fetched);
+ EXPECT_EQ(callback.outcome().SessionErrorForTesting()->type,
+ SessionError::kSubdomainRegistrationWellKnownUnavailable);
+}
+
TEST_F(RegistrationTest, FederatedSuccess) {
crypto::ScopedFakeUnexportableKeyProvider scoped_fake_key_provider;
Original Bug Report
DBSC subdomain authorization bypass via open redirect
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: The Device Bound Session Credentials (DBSC) implementation incorrectly evaluates subdomain authorization when an open redirect is involved during registration. It validates the session scope against the pre-redirect URL but creates the session bound to the post-redirect URL, allowing an attacker to bypass the .well-known registration check and hijack sessions for an entire site.
Affected files:
net/device_bound_sessions/registration_fetcher.ccnet/device_bound_sessions/url_fetcher.ccnet/device_bound_sessions/registration_fetcher_param.cc
Estimated timestamp from git blame: 2026-02-20
Background
Device Bound Session Credentials (DBSC) allows a server to bind a session to a device’s TPM. To prevent a compromised or untrusted subdomain from registering a session that applies to the entire site (e.g., attacker.example.com creating a session for *.example.com), DBSC enforces an authorization check. If a session configuration specifies include_site: true and the registration endpoint is a subdomain, Chrome fetches /.well-known/device-bound-sessions from the apex domain to verify the subdomain is authorized.
Potential Vulnerability
A vulnerability exists in RegistrationFetcherImpl where it suffers from identity confusion between the pre-redirect and post-redirect URLs when handling HTTP redirects during the session registration fetch.
When a DBSC registration request follows an HTTP redirect, net::URLFetcher transparently updates the current URL. However, RegistrationFetcherImpl retains the original, pre-redirect URL in its fetcher_endpoint_ member variable.
When the request completes in RegistrationFetcherImpl::OnRequestComplete (net/device_bound_sessions/registration_fetcher.cc):
- It parses the session configuration JSON and resolves the
refresh_urlusing the post-redirect URL (url_fetcher_->request().url()). - The
.well-knownauthorization check bypass logic evaluatesfetcher_endpoint_.GetHost() != session->origin().host(). - Because
fetcher_endpoint_holds the pre-redirect URL, this check compares the pre-redirect host against the requested session origin. If they match, the.well-knowncheck is completely bypassed.
Potential Exploit Scenario
An attacker can exploit this if they control a subdomain (attacker.example.com) and identify an open redirect on the apex domain (example.com/redirect).
- The victim visits the attacker’s subdomain:
https://attacker.example.com. - The attacker serves a DBSC registration header pointing to the apex open redirect:
Secure-Session-Registration: (ES256);path="https://example.com/redirect?url=https%3A%2F%2Fattacker.example.com%2Fdbsc" - Chrome parses this and verifies that the pre-redirect URL (
https://example.com/redirect...) is same-site with the request URL. RegistrationFetcherImplis created withfetcher_endpoint_set tohttps://example.com/redirect....- The request is initiated and transparently follows the 302 redirect to
https://attacker.example.com/dbsc. - The attacker’s endpoint responds with a 200 OK and a DBSC configuration specifying
"origin": "https://example.com","include_site": true, and a relative"refresh_url": "/refresh". Session::CreateIfValidvalidates the configuration. It resolves the relative refresh URL against the post-redirect URL, yieldinghttps://attacker.example.com/refresh. It also confirms the scope origin (example.com) is same-site with the post-redirect URL.- The authorization bypass check evaluates
fetcher_endpoint_.GetHost() != session->origin().host(). Becausefetcher_endpoint_isexample.comand the session origin isexample.com, the check evaluates tofalse, bypassing the.well-knowncheck. - A site-wide DBSC session is installed. Subsequent requests to
*.example.comwill trigger DBSC, sending fresh TPM-signed JWTs to the attacker’s endpoint (https://attacker.example.com/refresh).
(Note: Our tooling does not yet have the ability to run code to confirm this exploit chain dynamically, but the static analysis strongly supports it.)
Suggested Fix
Ensure that the authorization check in RegistrationFetcherImpl::OnRequestComplete uses the final, post-redirect URL. Specifically, line 731 in registration_fetcher.cc should be updated to compare the session->origin().host() against url_fetcher_->request().url().host() instead of fetcher_endpoint_.GetHost().
Alternatively, consider whether DBSC registration fetches should follow redirects at all. If they should, fetcher_endpoint_ should be updated when a redirect is followed, or DBSC should enforce that redirects do not cross origin boundaries.
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.