CVE-2026-113183
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/common/webid/identity_url_loader_throttle.cc |
modified | |
TEST_Fcontent/common/webid/identity_url_loader_throttle_unittest.cc |
modified |
Files Changed
content/common/webid/identity_url_loader_throttle_unittest.cccontent/common/webid/identity_url_loader_throttle.hcontent/browser/loader/url_loader_throttles.cccontent/shell/renderer/shell_content_renderer_client.cccontent/public/common/web_identity.hchrome/renderer/url_loader_throttle_provider_impl.cccontent/common/webid/identity_url_loader_throttle.ccheadless/lib/renderer/headless_content_renderer_client.cc
Patch
From 316307c398a462e0e969727307b0404200016492 Mon Sep 17 00:00:00 2001 From: Yi Gu <[email protected]> Date: Mon, 14 Sep 2026 21:20:33 -0700 Subject: [PATCH] [FedCM] Fix Set-Login same-site bypass in SXG cert fetches When creating throttles for top-level navigation, the getter callback reads the destination from the navigation request (which is kDocument). Signed Exchange (SXG) certificate fetches reuse this getter to generate throttles, causing the identity throttle to use the navigation's destination instead of the cert fetch's destination, thereby skipping same-site enforcement. See NavigationURLLoaderImpl::CreateURLLoaderThrottles() (which always uses resource_request()) and NavigationURLLoaderImpl:: CreateSignedExchangeRequestHandler (which uses the former function). This fix instead requests request.destination in IdentityUrlLoaderThrottle::WillStartRequest and passes it to the login status check, which is guaranteed to be correct. This ensures that cert fetches are correctly identified as non-document requests and same-site constraints are enforced. Fixed: 507227196 Change-Id: I55b637a784e0727c251db0202f02d975c349f961 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7800003 Reviewed-by: Nicolás Peña <[email protected]> Reviewed-by: Andrey Kosyakov <[email protected]> Commit-Queue: Yi Gu <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1697790} --- diff --git a/chrome/renderer/url_loader_throttle_provider_impl.cc b/chrome/renderer/url_loader_throttle_provider_impl.cc index d49edab1..c6735f9 100644 --- a/chrome/renderer/url_loader_throttle_provider_impl.cc +++ b/chrome/renderer/url_loader_throttle_provider_impl.cc @@ -283,6 +283,7 @@ [](const blink::LocalFrameToken& token, const scoped_refptr<base::SequencedTaskRunner> main_thread_task_runner, + network::mojom::RequestDestination destination, const std::optional<url::Origin>& initiator, const url::Origin& idp_origin, blink::mojom::IdpSigninStatus status) { diff --git a/content/browser/loader/url_loader_throttles.cc b/content/browser/loader/url_loader_throttles.cc index 72dabf6..b5e38a8 100644 --- a/content/browser/loader/url_loader_throttles.cc +++ b/content/browser/loader/url_loader_throttles.cc @@ -117,9 +117,17 @@ } if (auto throttle = MaybeCreateIdentityUrlLoaderThrottle( - base::BindRepeating(webid::SetIdpSigninStatus, - browser_context->GetWeakPtr(), - request.destination, frame_tree_node_id), + base::BindRepeating( + [](base::WeakPtr<content::BrowserContext> context, + FrameTreeNodeId ftn_id, + network::mojom::RequestDestination destination, + const std::optional<url::Origin>& initiator, + const url::Origin& idp_origin, + blink::mojom::IdpSigninStatus status) { + webid::SetIdpSigninStatus(context, destination, ftn_id, + initiator, idp_origin, status); + }, + browser_context->GetWeakPtr(), frame_tree_node_id), GetSetLoginHeaderDataDecoderParser())) { throttles.push_back(std::move(throttle)); } diff --git a/content/common/webid/identity_url_loader_throttle.cc b/content/common/webid/identity_url_loader_throttle.cc index 8caa975..f6384290 100644 --- a/content/common/webid/identity_url_loader_throttle.cc +++ b/content/common/webid/identity_url_loader_throttle.cc @@ -97,11 +97,12 @@ set_idp_status_cb_ = base::BindRepeating( [](scoped_refptr<base::SequencedTaskRunner> task_runner, SetIdpStatusCallback original_cb, + network::mojom::RequestDestination destination, const std::optional<url::Origin>& initiator, const url::Origin& idp_origin, blink::mojom::IdpSigninStatus status) { - task_runner->PostTask(FROM_HERE, - base::BindOnce(std::move(original_cb), initiator, - idp_origin, status)); + task_runner->PostTask( + FROM_HERE, base::BindOnce(std::move(original_cb), destination, + initiator, idp_origin, status)); }, base::SequencedTaskRunner::GetCurrentDefault(), std::move(set_idp_status_cb_)); @@ -112,6 +113,7 @@ bool* defer) { request_url_ = request->url; request_initiator_ = request->request_initiator; + request_destination_ = request->destination; } void IdentityUrlLoaderThrottle::WillProcessResponse( @@ -187,13 +189,13 @@ if (*token == kSetLoginHeaderValueLoggedIn) { // Mark IDP as logged in VLOG(1) << "IDP signed in: " << idp_origin.Serialize(); - set_idp_status_cb_.Run(request_initiator_, idp_origin, - IdpSigninStatus::kSignedIn); + set_idp_status_cb_.Run(request_destination_, request_initiator_, + idp_origin, IdpSigninStatus::kSignedIn); } else if (*token == kSetLoginHeaderValueLoggedOut) { // Mark IDP as logged out VLOG(1) << "IDP signed out: " << idp_origin.Serialize(); - set_idp_status_cb_.Run(request_initiator_, idp_origin, - IdpSigninStatus::kSignedOut); + set_idp_status_cb_.Run(request_destination_, request_initiator_, + idp_origin, IdpSigninStatus::kSignedOut); } } diff --git a/content/common/webid/identity_url_loader_throttle.h b/content/common/webid/identity_url_loader_throttle.h index ec0fb93e..1b025bf 100644 --- a/content/common/webid/identity_url_loader_throttle.h +++ b/content/common/webid/identity_url_loader_throttle.h @@ -15,6 +15,7 @@ #include "content/common/content_export.h" #include "content/public/common/web_identity.h" #include "net/http/structured_headers.h" +#include "services/network/public/mojom/fetch_api.mojom.h" #include "third_party/blink/public/common/loader/url_loader_throttle.h" #include "url/gurl.h" #include "url/origin.h" @@ -57,6 +58,8 @@ GURL request_url_; std::optional<url::Origin> request_initiator_; + network::mojom::RequestDestination request_destination_ = + network::mojom::RequestDestination::kEmpty; SetIdpStatusCallback set_idp_status_cb_; ParseSetLoginHeaderCallback parse_set_login_header_cb_; bool is_inside_handler_response_ = false; diff --git a/content/public/common/web_identity.h b/content/public/common/web_identity.h index 08bfaa4..2f4d66b 100644 --- a/content/public/common/web_identity.h +++ b/content/public/common/web_identity.h @@ -10,6 +10,7 @@ #include "base/functional/callback.h" #include "content/common/content_export.h" #include "net/http/structured_headers.h" +#include "services/network/public/mojom/fetch_api.mojom-forward.h" #include "third_party/blink/public/common/loader/url_loader_throttle.h" namespace url { @@ -22,11 +23,11 @@ namespace content { -typedef base::RepeatingCallback<void( - const std::optional<url::Origin>& initiator, - const url::Origin& idp_origin, - blink::mojom::IdpSigninStatus status)> - SetIdpStatusCallback; +using SetIdpStatusCallback = + base::RepeatingCallback<void(network::mojom::RequestDestination destination, + const std::optional<url::Origin>& initiator, + const url::Origin& idp_origin, + blink::mojom::IdpSigninStatus status)>; using ParseSetLoginHeaderCallback = base::RepeatingCallback<void( const std::string& header_value, diff --git a/content/shell/renderer/shell_content_renderer_client.cc b/content/shell/renderer/shell_content_renderer_client.cc index 5281e4d..34e4055 100644 --- a/content/shell/renderer/shell_content_renderer_client.cc +++ b/content/shell/renderer/shell_content_renderer_client.cc @@ -212,6 +212,7 @@ [](const blink::LocalFrameToken& token, const scoped_refptr<base::SequencedTaskRunner> main_thread_task_runner, + network::mojom::RequestDestination destination, const std::optional<url::Origin>& initiator, const url::Origin& idp_origin, blink::mojom::IdpSigninStatus status) { diff --git a/headless/lib/renderer/headless_content_renderer_client.cc b/headless/lib/renderer/headless_content_renderer_client.cc index e37d8724..df349d3 100644 --- a/headless/lib/renderer/headless_content_renderer_client.cc +++ b/headless/lib/renderer/headless_content_renderer_client.cc @@ -56,6 +56,7 @@ [](const blink::LocalFrameToken& token, const scoped_refptr<base::SequencedTaskRunner> main_thread_task_runner, + network::mojom::RequestDestination destination, const std::optional<url::Origin>& initiator, const url::Origin& idp_origin, blink::mojom::IdpSigninStatus status) { diff --git a/content/common/webid/identity_url_loader_throttle_unittest.cc b/content/common/webid/identity_url_loader_throttle_unittest.cc index 23da808..b647459 100644 --- a/content/common/webid/identity_url_loader_throttle_unittest.cc +++ b/content/common/webid/identity_url_loader_throttle_unittest.cc @@ -44,16 +44,20 @@ return GetSetLoginHeaderInProcessParser(); } - void SetIdpStatus(const std::optional<url::Origin>& initiator, + void SetIdpStatus(network::mojom::RequestDestination destination, + const std::optional<url::Origin>& initiator, const url::Origin& idp_origin, IdpSigninStatus status) { ++cb_num_calls_; + cb_destination_ = destination; cb_initiator_ = initiator; cb_idp_origin_ = idp_origin; cb_signin_status_ = status; } int cb_num_calls_ = 0; + network::mojom::RequestDestination cb_destination_ = + network::mojom::RequestDestination::kEmpty; std::optional<url::Origin> cb_initiator_; url::Origin cb_idp_origin_; IdpSigninStatus cb_signin_status_ = IdpSigninStatus::kSignedOut; @@ -76,6 +80,7 @@ network::ResourceRequest request; request.url = GURL("https://accounts.idp.example/"); request.request_initiator = url::Origin::Create(GURL("https://rp.example/")); + request.destination = network::mojom::RequestDestination::kDocument; bool defer = false; throttle->WillStartRequest(&request, &defer); @@ -96,6 +101,7 @@ EXPECT_EQ(url::Origin::Create(GURL("https://accounts.idp.example/")), cb_idp_origin_); EXPECT_EQ(request.request_initiator, cb_initiator_); + EXPECT_EQ(request.destination, cb_destination_); EXPECT_EQ(0, delegate.resume_called_); } @@ -129,6 +135,32 @@ EXPECT_EQ(0, delegate.resume_called_); } +TEST_F(IdentityUrlLoaderThrottleTest, NonDefaultDestination) { + TestDelegate delegate; + std::unique_ptr<blink::URLLoaderThrottle> throttle = + MaybeCreateIdentityUrlLoaderThrottle(CreateCallback(), + CreateParseCallback()); + ASSERT_NE(nullptr, throttle); + throttle->set_delegate(&delegate); + + network::ResourceRequest request; + request.url = GURL("https://accounts.idp.example/"); + request.destination = network::mojom::RequestDestination::kScript; + bool defer = false; + + throttle->WillStartRequest(&request, &defer); + EXPECT_FALSE(defer); + + network::mojom::URLResponseHead response_head; + response_head.headers = net::HttpResponseHeaders::TryToCreate( + "HTTP/1.1 200 OK\nSet-Login: logged-in\n"); + throttle->WillProcessResponse(request.url, &response_head, &defer); + EXPECT_FALSE(defer); + + EXPECT_EQ(1, cb_num_calls_); + EXPECT_EQ(network::mojom::RequestDestination::kScript, cb_destination_); +} + TEST_F(IdentityUrlLoaderThrottleTest, InvalidHeader) { TestDelegate delegate; std::unique_ptr<blink::URLLoaderThrottle> throttle =
Regression Test / PoC
diff --git a/content/common/webid/identity_url_loader_throttle_unittest.cc b/content/common/webid/identity_url_loader_throttle_unittest.cc
index 23da808..b647459 100644
--- a/content/common/webid/identity_url_loader_throttle_unittest.cc
+++ b/content/common/webid/identity_url_loader_throttle_unittest.cc
@@ -44,16 +44,20 @@
return GetSetLoginHeaderInProcessParser();
}
- void SetIdpStatus(const std::optional<url::Origin>& initiator,
+ void SetIdpStatus(network::mojom::RequestDestination destination,
+ const std::optional<url::Origin>& initiator,
const url::Origin& idp_origin,
IdpSigninStatus status) {
++cb_num_calls_;
+ cb_destination_ = destination;
cb_initiator_ = initiator;
cb_idp_origin_ = idp_origin;
cb_signin_status_ = status;
}
int cb_num_calls_ = 0;
+ network::mojom::RequestDestination cb_destination_ =
+ network::mojom::RequestDestination::kEmpty;
std::optional<url::Origin> cb_initiator_;
url::Origin cb_idp_origin_;
IdpSigninStatus cb_signin_status_ = IdpSigninStatus::kSignedOut;
@@ -76,6 +80,7 @@
network::ResourceRequest request;
request.url = GURL("https://accounts.idp.example/");
request.request_initiator = url::Origin::Create(GURL("https://rp.example/"));
+ request.destination = network::mojom::RequestDestination::kDocument;
bool defer = false;
throttle->WillStartRequest(&request, &defer);
@@ -96,6 +101,7 @@
EXPECT_EQ(url::Origin::Create(GURL("https://accounts.idp.example/")),
cb_idp_origin_);
EXPECT_EQ(request.request_initiator, cb_initiator_);
+ EXPECT_EQ(request.destination, cb_destination_);
EXPECT_EQ(0, delegate.resume_called_);
}
@@ -129,6 +135,32 @@
EXPECT_EQ(0, delegate.resume_called_);
}
+TEST_F(IdentityUrlLoaderThrottleTest, NonDefaultDestination) {
+ TestDelegate delegate;
+ std::unique_ptr<blink::URLLoaderThrottle> throttle =
+ MaybeCreateIdentityUrlLoaderThrottle(CreateCallback(),
+ CreateParseCallback());
+ ASSERT_NE(nullptr, throttle);
+ throttle->set_delegate(&delegate);
+
+ network::ResourceRequest request;
+ request.url = GURL("https://accounts.idp.example/");
+ request.destination = network::mojom::RequestDestination::kScript;
+ bool defer = false;
+
+ throttle->WillStartRequest(&request, &defer);
+ EXPECT_FALSE(defer);
+
+ network::mojom::URLResponseHead response_head;
+ response_head.headers = net::HttpResponseHeaders::TryToCreate(
+ "HTTP/1.1 200 OK\nSet-Login: logged-in\n");
+ throttle->WillProcessResponse(request.url, &response_head, &defer);
+ EXPECT_FALSE(defer);
+
+ EXPECT_EQ(1, cb_num_calls_);
+ EXPECT_EQ(network::mojom::RequestDestination::kScript, cb_destination_);
+}
+
TEST_F(IdentityUrlLoaderThrottleTest, InvalidHeader) {
TestDelegate delegate;
std::unique_ptr<blink::URLLoaderThrottle> throttle =
Original Bug Report
FedCM Set-Login same-site bypass via Signed Exchange cert fetches
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 logic flaw in how Signed Exchange (SXG) certificate fetches are handled allows a cross-origin site to bypass FedCM Set-Login same-site enforcement. Because the cert fetch reuses the URLLoaderThrottles generated for the top-level navigation, the identity throttle incorrectly assumes a kDocument destination. This allows a tracker to maliciously mark itself as signed-in globally without a same-site visit.
Affected files:
content/browser/webid/webid_utils.cccontent/browser/loader/url_loader_throttles.cccontent/browser/web_package/signed_exchange_cert_fetcher_factory.cccontent/common/webid/identity_url_loader_throttle.ccchrome/browser/webid/federated_identity_permission_context.cc
Estimated timestamp from git blame: 2026-04-22
Root Cause
The FedCM Set-Login header same-site enforcement in webid::SetIdpSigninStatus contains a logic flaw that allows a bypass during Signed Exchange (SXG) certificate fetches.
When processing a Set-Login header, webid::SetIdpSigninStatus (in content/browser/webid/webid_utils.cc) performs two security checks to prevent trackers from arbitrarily setting a user’s login status:
- If the request destination is not
kDocument, it ensures the request initiator is same-site with the Identity Provider (IDP) origin. - It verifies that the IDP origin is same-site with all frame ancestors, starting from the
FrameTreeNode’s parent.
While this logic works for standard requests, it breaks during Signed Exchange navigations due to throttle reuse. When a navigation is initiated, NavigationURLLoaderImpl creates throttles based on the main resource_request(), binding the IdentityUrlLoaderThrottle callback with request.destination = network::mojom::RequestDestination::kDocument.
If the response is a Signed Exchange, a SignedExchangeLoader is created. This loader initializes a SignedExchangeCertFetcherFactory, passing it the URLLoaderThrottlesGetter callback from the original navigation request. When the SignedExchangeHandler parses the SXG signature and needs to fetch the cert-url, it invokes this factory.
The factory executes the throttle getter, generating a new IdentityUrlLoaderThrottle, but because it relies on the original navigation request’s state, the throttle’s callback is still bound with destination = kDocument.
If the cert-url endpoint responds with a Set-Login header, IdentityUrlLoaderThrottle parses it and calls webid::SetIdpSigninStatus. Because the destination was incorrectly bound as kDocument, the cross-origin initiator check is skipped entirely. Furthermore, because it is a top-level navigation, the ancestor check passes (the parent frame is null). As a result, the cert-url origin is marked as signed-in profile-wide without ever having been visited in a same-site context.
Potential Exploit Steps
Note: These are potential steps based on code analysis; we do not currently have a working proof of concept.
- An attacker configures a cross-origin tracking domain to serve a
Set-Login: logged-inheader from a specific endpoint. - The attacker hosts a malicious Signed Exchange (SXG) file on an attacker-controlled server. They configure the SXG’s signature header to use the cross-origin tracker’s endpoint as the
cert-url. - The attacker tricks a victim into navigating to the hosted SXG file.
- The browser begins a top-level navigation, discovers the SXG, and initiates a background fetch to the
cert-urlto verify the signature. - The tracker’s endpoint responds with the
Set-Loginheader. - The browser parses the header, bypasses the same-site FedCM checks due to the inherited
kDocumentdestination state, and marks the tracker as signed-in globally in the user’s profile.
Suggested Fix
The SignedExchangeCertFetcher should not blindly inherit the kDocument destination from the top-level navigation when generating URLLoaderThrottles. The throttles generated for the certificate fetch should reflect its nature as a subresource fetch, or the IdentityUrlLoaderThrottle should specifically check if it is being executed within the context of a Signed Exchange certificate fetch and enforce same-site constraints accordingly.
Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f
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.