CVE-2026-8013
Overview
Background
- FedCM login status / `Set-Login` header
- an identity provider (IdP) can report its sign-in state to the browser via a
Set-LoginHTTP response header, which the browser records throughblink::SetIdpSigninStatus. - `Set-Login` processing in the browser process
- some subresource loads are handled on the browser side (e.g. 103 Early Hints preloads), so their response headers are processed outside the IdP’s own document.
- Initiator vs resource origin
- the initiator is the origin that caused the load, while the resource/IdP origin is the URL being fetched; a same-origin check must compare against the initiator to prevent cross-origin spoofing.
- `url_loader_throttle_provider_impl.cc`
- the renderer-side throttle provider wiring a callback that forwards IdP sign-in status to the main thread.
Root Cause Analysis
The callback that relayed IdP sign-in status took a single const url::Origin& origin and used it directly for blink::SetIdpSigninStatus, without distinguishing the load’s initiator from the resource origin. Per the commit, because subresources (103 Early Hints preloads and others) are also loaded on the browser side, a cross-origin subresource could supply a Set-Login header and have its status applied without an initiator same-origin check — violating the invariant that only a same-origin IdP context may set that IdP’s login status.
The fix changes the callback signature to accept both const std::optional<url::Origin>& initiator and const url::Origin& idp_origin, renaming the former single origin so the initiator is now available for the same-origin check the commit describes. In the visible hunk the body simply forwards idp_origin to blink::SetIdpSigninStatus; the actual initiator-vs-URL same-origin enforcement is not shown in this hunk (the enclosing function name is also not present in the @@ -269 header), so where the initiator is compared is (inferred) to live in the surrounding/browser-side code not fully included. The shipped browser test IdpSigninStatusBrowserTest exercises a cross-origin Early-Hints preload and asserts it cannot set the login status.
initiator alongside idp_origin so the same-origin check can be enforced.Attack Path
- Attacker page preloads a cross-origin IdP
an RP/attacker page emits a 103 Early Hints
Link: rel=preloadpointing at a victim IdP URL, which the browser process begins loading as a subresource. - Serve a `Set-Login` header
the attacker-influenced response carries
Set-Login: logged-in(as the test’s interceptor does) on a load whose initiator differs from the IdP origin. - Status applied without initiator check
pre-patch the callback used the single resource
originand forwarded it toblink::SetIdpSigninStatus, recording the IdP’s login status from a cross-origin context. - Spoof FedCM login state
the victim IdP’s sign-in status is set/altered without a same-origin initiator, which the browser test
IdpSigninStatusBrowserTestconfirms must be blocked; the fix threadsinitiatorso this cross-origin set is rejected.
Impact Assessment
Set-Login header. The advisory rates this low severity and classifies it as insufficient validation of untrusted input in FedCM. The impact is spoofing/manipulation of FedCM sign-in state; no memory-safety or further escalation is demonstrated by the diff. No in-the-wild exploitation is indicated.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/renderer/url_loader_throttle_provider_impl.cc |
modified | |
IdpSigninStatusBrowserTestcontent/browser/webid/idp_signin_status_browsertest.cc |
modified | |
ifcontent/browser/webid/idp_signin_status_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/webid/idp_signin_status_browsertest.cc |
modified |
Files Changed
chrome/renderer/url_loader_throttle_provider_impl.cccontent/browser/webid/idp_signin_status_browsertest.cc
Audit Directions
- Browser-side header processing without initiator checksaudit features that act on response headers (
Set-Login, CSP-report, etc.) for subresources loaded in the browser process, confirming they validate the initiator, not just the resource URL. - Early Hints / preload side effectsreview code reachable from 103 Early Hints and speculative loads, where a cross-origin response can be processed before a document context is established.
- Origin parameter conflationflag callbacks/APIs that carry a single
originwhere both initiator and resource origin are relevant; same-origin decisions must name which origin they compare.
Patch
From 5932bfd9d475fb2d7f0197c725412ac4f98281de Mon Sep 17 00:00:00 2001 From: Christian Biesinger <[email protected]> Date: Fri, 10 Apr 2026 10:06:00 -0700 Subject: [PATCH] [FedCM] Check initiator for login status headers When processing Set-Login headers in the browser process, we need to also check the URL against the initiator for same-origin checks because in various circumstances subresources also get loaded on the browser side (103 early hints and others). Fixed: 497427430 Change-Id: I8f1bf522c801eac6a378dc070d88c6c15e24f019 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7722580 Reviewed-by: Nicolás Peña <[email protected]> Reviewed-by: Colin Blundell <[email protected]> Reviewed-by: Peter Kvitek <[email protected]> Reviewed-by: Camille Lamy <[email protected]> Commit-Queue: Christian Biesinger <[email protected]> Cr-Commit-Position: refs/heads/main@{#1612943} --- diff --git a/chrome/renderer/url_loader_throttle_provider_impl.cc b/chrome/renderer/url_loader_throttle_provider_impl.cc index 534e10ea..edf6b60 100644 --- a/chrome/renderer/url_loader_throttle_provider_impl.cc +++ b/chrome/renderer/url_loader_throttle_provider_impl.cc @@ -269,16 +269,17 @@ [](const blink::LocalFrameToken& token, const scoped_refptr<base::SequencedTaskRunner> main_thread_task_runner, - const url::Origin& origin, + const std::optional<url::Origin>& initiator, + const url::Origin& idp_origin, blink::mojom::IdpSigninStatus status) { if (content::RenderThread::IsMainThread()) { - blink::SetIdpSigninStatus(token, origin, status); + blink::SetIdpSigninStatus(token, idp_origin, status); return; } if (main_thread_task_runner) { main_thread_task_runner->PostTask( FROM_HERE, base::BindOnce(&blink::SetIdpSigninStatus, token, - origin, status)); + idp_origin, status)); } }, local_frame_token.value(), main_thread_task_runner_)); diff --git a/content/browser/webid/idp_signin_status_browsertest.cc b/content/browser/webid/idp_signin_status_browsertest.cc new file mode 100644 index 0000000..7f11a05 --- /dev/null +++ b/content/browser/webid/idp_signin_status_browsertest.cc @@ -0,0 +1,218 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/test/scoped_feature_list.h" +#include "content/browser/webid/webid_utils.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/web_contents.h" +#include "content/public/browser/webid/federated_identity_permission_context_delegate.h" +#include "content/public/common/content_features.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" +#include "content/public/test/url_loader_interceptor.h" +#include "content/shell/browser/shell.h" +#include "net/dns/mock_host_resolver.h" +#include "services/network/public/mojom/early_hints.mojom.h" +#include "services/network/public/mojom/link_header.mojom.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "url/gurl.h" +#include "url/origin.h" + +namespace content { + +class IdpSigninStatusBrowserTest : public ContentBrowserTest { + public: + IdpSigninStatusBrowserTest() = default; + + void SetUpOnMainThread() override { + ContentBrowserTest::SetUpOnMainThread(); + host_resolver()->AddRule("*", "127.0.0.1"); + } + + bool IsIdpSignedIn(const url::Origin& origin) { + auto* delegate = shell() + ->web_contents() + ->GetBrowserContext() + ->GetFederatedIdentityPermissionContext(); + return delegate->GetIdpSigninStatus(origin).has_value() && + *delegate->GetIdpSigninStatus(origin); + } + + using InterceptorCallback = + base::RepeatingCallback<bool(URLLoaderInterceptor::RequestParams*)>; + + std::unique_ptr<URLLoaderInterceptor> CreateIdpSigninInterceptor( + const GURL& idp_url, + const GURL& rp_url = GURL(), + InterceptorCallback rp_callback = base::NullCallback()) { + return std::make_unique<URLLoaderInterceptor>(base::BindRepeating( + [](const GURL& idp_url, const GURL& rp_url, + InterceptorCallback rp_callback, + URLLoaderInterceptor::RequestParams* params) { + if (!rp_url.is_empty() && params->url_request.url == rp_url) { + return rp_callback.Run(params); + } + if (params->url_request.url == idp_url) { + URLLoaderInterceptor::WriteResponse( + "HTTP/1.1 200 OK\nContent-Type: text/html\n" + "Set-Login: logged-in\n\n", + "<html><body>IDP</body></html>", params->client.get()); + return true; + } + return false; + }, + idp_url, rp_url, std::move(rp_callback))); + } + + std::unique_ptr<URLLoaderInterceptor> CreateEarlyHintsInterceptor( + const GURL& rp_url, + const GURL& idp_url) { + return CreateIdpSigninInterceptor( + idp_url, rp_url, + base::BindRepeating( + [](const GURL& idp_url, + URLLoaderInterceptor::RequestParams* params) { + auto early_hints = network::mojom::EarlyHints::New(); + early_hints->headers = network::mojom::ParsedHeaders::New(); + auto link = network::mojom::LinkHeader::New(); + link->href = idp_url; + link->rel = network::mojom::LinkRelAttribute::kPreload; + link->as = network::mojom::LinkAsAttribute::kScript; + early_hints->headers->link_headers.push_back(std::move(link)); + params->client->OnReceiveEarlyHints(std::move(early_hints)); + + URLLoaderInterceptor::WriteResponse( + "HTTP/1.1 200 OK\nContent-Type: text/html\n\n", + "<html><body>RP</body></html>", params->client.get()); + return true; + }, + idp_url)); + } +}; + +// Test that a cross-origin subresource preload via Early Hints cannot set the +// IdP sign-in status. +IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, + EarlyHintsCrossOriginSubresource) { + GURL rp_url("https://rp.test/index.html"); + GURL idp_url("https://idp.test/set-login"); + url::Origin idp_origin = url::Origin::Create(idp_url); + + auto interceptor = CreateEarlyHintsInterceptor(rp_url, idp_url); + + EXPECT_TRUE(NavigateToURL(shell(), rp_url)); + + // The IdP sign-in status should NOT be set because it's a cross-origin + // subresource preload. + EXPECT_FALSE(IsIdpSignedIn(idp_origin)); +} + +// Test that a same-origin subresource preload via Early Hints CAN set the +// IdP sign-in status. +IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, + EarlyHintsSameOriginSubresource) { + GURL rp_url("https://rp.test/index.html"); + GURL idp_url("https://rp.test/set-login"); + url::Origin idp_origin = url::Origin::Create(idp_url); + + auto interceptor = CreateEarlyHintsInterceptor(rp_url, idp_url); + + EXPECT_TRUE(NavigateToURL(shell(), rp_url)); + + // The IdP sign-in status SHOULD be set because it's a same-origin + // subresource preload. + EXPECT_TRUE(IsIdpSignedIn(idp_origin)); +} + +// Test that a top-level navigation CAN set its own IdP sign-in status. +IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, TopLevelNavigation) { + GURL idp_url("https://idp.test/index.html"); + url::Origin idp_origin = url::Origin::Create(idp_url); + + auto interceptor = CreateIdpSigninInterceptor(idp_url); + + EXPECT_TRUE(NavigateToURL(shell(), idp_url)); + + // The IdP sign-in status SHOULD be set because it's a top-level navigation. + EXPECT_TRUE(IsIdpSignedIn(idp_origin)); +} + +// Test that a regular cross-origin subresource load cannot set the IdP sign-in +// status. +IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, CrossOriginSubresource) { + GURL rp_url("https://rp.test/index.html"); + GURL idp_url("https://idp.test/set-login"); + url::Origin idp_origin = url::Origin::Create(idp_url);
Regression Test / PoC
diff --git a/content/browser/webid/idp_signin_status_browsertest.cc b/content/browser/webid/idp_signin_status_browsertest.cc
new file mode 100644
index 0000000..7f11a05
--- /dev/null
+++ b/content/browser/webid/idp_signin_status_browsertest.cc
@@ -0,0 +1,218 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/test/scoped_feature_list.h"
+#include "content/browser/webid/webid_utils.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/browser/webid/federated_identity_permission_context_delegate.h"
+#include "content/public/common/content_features.h"
+#include "content/public/test/browser_test.h"
+#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/url_loader_interceptor.h"
+#include "content/shell/browser/shell.h"
+#include "net/dns/mock_host_resolver.h"
+#include "services/network/public/mojom/early_hints.mojom.h"
+#include "services/network/public/mojom/link_header.mojom.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
+#include "url/origin.h"
+
+namespace content {
+
+class IdpSigninStatusBrowserTest : public ContentBrowserTest {
+ public:
+ IdpSigninStatusBrowserTest() = default;
+
+ void SetUpOnMainThread() override {
+ ContentBrowserTest::SetUpOnMainThread();
+ host_resolver()->AddRule("*", "127.0.0.1");
+ }
+
+ bool IsIdpSignedIn(const url::Origin& origin) {
+ auto* delegate = shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetFederatedIdentityPermissionContext();
+ return delegate->GetIdpSigninStatus(origin).has_value() &&
+ *delegate->GetIdpSigninStatus(origin);
+ }
+
+ using InterceptorCallback =
+ base::RepeatingCallback<bool(URLLoaderInterceptor::RequestParams*)>;
+
+ std::unique_ptr<URLLoaderInterceptor> CreateIdpSigninInterceptor(
+ const GURL& idp_url,
+ const GURL& rp_url = GURL(),
+ InterceptorCallback rp_callback = base::NullCallback()) {
+ return std::make_unique<URLLoaderInterceptor>(base::BindRepeating(
+ [](const GURL& idp_url, const GURL& rp_url,
+ InterceptorCallback rp_callback,
+ URLLoaderInterceptor::RequestParams* params) {
+ if (!rp_url.is_empty() && params->url_request.url == rp_url) {
+ return rp_callback.Run(params);
+ }
+ if (params->url_request.url == idp_url) {
+ URLLoaderInterceptor::WriteResponse(
+ "HTTP/1.1 200 OK\nContent-Type: text/html\n"
+ "Set-Login: logged-in\n\n",
+ "<html><body>IDP</body></html>", params->client.get());
+ return true;
+ }
+ return false;
+ },
+ idp_url, rp_url, std::move(rp_callback)));
+ }
+
+ std::unique_ptr<URLLoaderInterceptor> CreateEarlyHintsInterceptor(
+ const GURL& rp_url,
+ const GURL& idp_url) {
+ return CreateIdpSigninInterceptor(
+ idp_url, rp_url,
+ base::BindRepeating(
+ [](const GURL& idp_url,
+ URLLoaderInterceptor::RequestParams* params) {
+ auto early_hints = network::mojom::EarlyHints::New();
+ early_hints->headers = network::mojom::ParsedHeaders::New();
+ auto link = network::mojom::LinkHeader::New();
+ link->href = idp_url;
+ link->rel = network::mojom::LinkRelAttribute::kPreload;
+ link->as = network::mojom::LinkAsAttribute::kScript;
+ early_hints->headers->link_headers.push_back(std::move(link));
+ params->client->OnReceiveEarlyHints(std::move(early_hints));
+
+ URLLoaderInterceptor::WriteResponse(
+ "HTTP/1.1 200 OK\nContent-Type: text/html\n\n",
+ "<html><body>RP</body></html>", params->client.get());
+ return true;
+ },
+ idp_url));
+ }
+};
+
+// Test that a cross-origin subresource preload via Early Hints cannot set the
+// IdP sign-in status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest,
+ EarlyHintsCrossOriginSubresource) {
+ GURL rp_url("https://rp.test/index.html");
+ GURL idp_url("https://idp.test/set-login");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateEarlyHintsInterceptor(rp_url, idp_url);
+
+ EXPECT_TRUE(NavigateToURL(shell(), rp_url));
+
+ // The IdP sign-in status should NOT be set because it's a cross-origin
+ // subresource preload.
+ EXPECT_FALSE(IsIdpSignedIn(idp_origin));
+}
+
+// Test that a same-origin subresource preload via Early Hints CAN set the
+// IdP sign-in status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest,
+ EarlyHintsSameOriginSubresource) {
+ GURL rp_url("https://rp.test/index.html");
+ GURL idp_url("https://rp.test/set-login");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateEarlyHintsInterceptor(rp_url, idp_url);
+
+ EXPECT_TRUE(NavigateToURL(shell(), rp_url));
+
+ // The IdP sign-in status SHOULD be set because it's a same-origin
+ // subresource preload.
+ EXPECT_TRUE(IsIdpSignedIn(idp_origin));
+}
+
+// Test that a top-level navigation CAN set its own IdP sign-in status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, TopLevelNavigation) {
+ GURL idp_url("https://idp.test/index.html");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateIdpSigninInterceptor(idp_url);
+
+ EXPECT_TRUE(NavigateToURL(shell(), idp_url));
+
+ // The IdP sign-in status SHOULD be set because it's a top-level navigation.
+ EXPECT_TRUE(IsIdpSignedIn(idp_origin));
+}
+
+// Test that a regular cross-origin subresource load cannot set the IdP sign-in
+// status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, CrossOriginSubresource) {
+ GURL rp_url("https://rp.test/index.html");
+ GURL idp_url("https://idp.test/set-login");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateIdpSigninInterceptor(
+ idp_url, rp_url,
+ base::BindRepeating([](URLLoaderInterceptor::RequestParams* params) {
+ URLLoaderInterceptor::WriteResponse(
+ "HTTP/1.1 200 OK\nContent-Type: text/html\n\n",
+ "<html><body>"
+ "<script src='https://idp.test/set-login'></script>"
+ "</body></html>",
+ params->client.get());
+ return true;
+ }));
+
+ EXPECT_TRUE(NavigateToURL(shell(), rp_url));
+
+ // The IdP sign-in status should NOT be set because it's a cross-origin
+ // subresource load.
+ EXPECT_FALSE(IsIdpSignedIn(idp_origin));
+}
+
+// Test that a same-origin iframe CAN set the IdP sign-in status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, SameOriginIframe) {
+ GURL rp_url("https://rp.test/index.html");
+ GURL idp_url("https://rp.test/iframe.html");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateIdpSigninInterceptor(
+ idp_url, rp_url,
+ base::BindRepeating([](URLLoaderInterceptor::RequestParams* params) {
+ URLLoaderInterceptor::WriteResponse(
+ "HTTP/1.1 200 OK\nContent-Type: text/html\n\n",
+ "<html><body>"
+ "<iframe src='https://rp.test/iframe.html'></iframe>"
+ "</body></html>",
+ params->client.get());
+ return true;
+ }));
+
+ EXPECT_TRUE(NavigateToURL(shell(), rp_url));
+
+ // The IdP sign-in status SHOULD be set because it's a same-origin iframe.
+ EXPECT_TRUE(IsIdpSignedIn(idp_origin));
+}
+
+// Test that a cross-origin iframe cannot set the IdP sign-in status.
+IN_PROC_BROWSER_TEST_F(IdpSigninStatusBrowserTest, CrossOriginIframe) {
+ GURL rp_url("https://rp.test/index.html");
+ GURL idp_url("https://idp.test/iframe.html");
+ url::Origin idp_origin = url::Origin::Create(idp_url);
+
+ auto interceptor = CreateIdpSigninInterceptor(
+ idp_url, rp_url,
+ base::BindRepeating([](URLLoaderInterceptor::RequestParams* params) {
+ URLLoaderInterceptor::WriteResponse(
+ "HTTP/1.1 200 OK\nContent-Type: text/html\n\n",
+ "<html><body>"
+ "<iframe src='https://idp.test/iframe.html'></iframe>"
+ "</body></html>",
+ params->client.get());
+ return true;
+ }));
+
+ EXPECT_TRUE(NavigateToURL(shell(), rp_url));
+
+ // The IdP sign-in status should NOT be set because it's a cross-origin
+ // iframe.
+ EXPECT_FALSE(IsIdpSignedIn(idp_origin));
+}
+
+} // namespace content
diff --git a/content/common/webid/identity_url_loader_throttle_unittest.cc b/content/common/webid/identity_url_loader_throttle_unittest.cc
index e6563ef..21090d9 100644
--- a/content/common/webid/identity_url_loader_throttle_unittest.cc
+++ b/content/common/webid/identity_url_loader_throttle_unittest.cc
@@ -28,15 +28,19 @@
base::Unretained(this));
}
- void SetIdpStatus(const url::Origin& origin, IdpSigninStatus status) {
+ void SetIdpStatus(const std::optional<url::Origin>& initiator,
+ const url::Origin& idp_origin,
+ IdpSigninStatus status) {
++cb_num_calls_;
- cb_origin_ = origin;
+ cb_initiator_ = initiator;
+ cb_idp_origin_ = idp_origin;
cb_signin_status_ = status;
}
base::HistogramTester histogram_tester_;
int cb_num_calls_ = 0;
- url::Origin cb_origin_;
+ std::optional<url::Origin> cb_initiator_;
+ url::Origin cb_idp_origin_;
IdpSigninStatus cb_signin_status_ = IdpSigninStatus::kSignedOut;
};
@@ -54,6 +58,7 @@
network::ResourceRequest request;
request.url = GURL("https://accounts.idp.example/");
+ request.request_initiator = url::Origin::Create(GURL("https://rp.example/"));
request.has_user_gesture = has_user_gesture;
bool defer = false;
@@ -73,7 +78,8 @@
EXPECT_EQ(1, cb_num_calls_);
EXPECT_EQ(signin_status, cb_signin_status_);
EXPECT_EQ(url::Origin::Create(GURL("https://accounts.idp.example/")),
- cb_origin_);
+ cb_idp_origin_);
+ EXPECT_EQ(request.request_initiator, cb_initiator_);
if (signin_status == IdpSigninStatus::kSignedIn) {
histogram_tester_.ExpectUniqueSample(
"Blink.FedCm.IdpSigninRequestInitiatedByUser", has_user_gesture, 1);
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 9c8b29e..576f0a05 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -1798,6 +1798,7 @@
"../browser/web_package/signed_exchange_subresource_prefetch_browsertest.cc",
"../browser/webid/delegation/jwt_signer_browsertest.cc",
"../browser/webid/document_metadata_browsertest.cc",
+ "../browser/webid/idp_signin_status_browsertest.cc",
"../browser/webid/navigation_interceptor_browsertest.cc",
"../browser/webid/test/mock_digital_identity_provider.cc",
"../browser/webid/test/mock_digital_identity_provider.h",
Original Bug Report
FedCM IdP sign-in status bypass via 103 Early Hints in main frames
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A logic error in FedCM’s sign-in status validation allows cross-origin resources preloaded via 103 Early Hints to bypass same-site restrictions and set their Identity Provider (IdP) sign-in status. This occurs because the validation checks the frame’s ancestors, which evaluates to null for top-level navigations, vacuously passing the security check. A malicious Relying Party could exploit this to enable cross-site tracking by a third-party tracker.
Affected files:
content/browser/webid/webid_utils.cccontent/browser/loader/url_loader_throttles.cccontent/browser/loader/navigation_early_hints_manager.cccontent/browser/loader/navigation_url_loader_impl.cccontent/common/webid/identity_url_loader_throttle.cc
Estimated timestamp from git blame: 2025-08-19
Background
FedCM implements a ‘sign-in status’ mechanism to prevent silent cross-site tracking. An Identity Provider (IdP) can set its status to ‘signed-in’ or ‘signed-out’ using the Set-Login HTTP response header. To prevent a malicious Relying Party (RP) from arbitrarily setting the sign-in status for a third-party tracker (which would later allow the tracker to receive credentialed FedCM requests), Chromium enforces that the Set-Login header must be delivered from a same-site frame or a same-site ancestor.
The Vulnerability
The validation logic in webid::SetIdpSigninStatus (content/browser/webid/webid_utils.cc) is flawed when checking ancestors for top-level frames. The function uses IsSameSiteWithAncestors(origin, frame_tree_node->parent()) to validate the IdP origin. When this is called for a navigation or a resource associated with the outermost main frame, frame_tree_node->parent() is nullptr.
The IsSameSiteWithAncestors implementation returns true for a null RenderFrameHost, meaning the check vacuously passes for all top-level frames.
While this logic might be intended for the main resource navigation itself (where the origin should be allowed to set its own status), it is incorrectly applied to subresource preloads triggered by 103 Early Hints.
When a top-level navigation to https://rp.test receives a 103 Early Hints response containing a Link: rel=preload header for a cross-origin resource like https://tracker.test/setlogin, the browser initiates a preload request. This request is handled by IdentityUrlLoaderThrottle (bound via CreateContentBrowserURLLoaderThrottles), which invokes the browser-side webid::SetIdpSigninStatus callback. Because the preload is associated with the main frame’s FrameTreeNodeId, the parent() check is bypassed, allowing tracker.test to persist its sign-in status despite being cross-origin to the RP.
In contrast, the renderer-side Mojo implementation of SetIdpSigninStatus in content/browser/webid/request_service.cc correctly checks the origin against the frame host itself (IsSameSiteWithAncestors(idp_origin, &render_frame_host())), which prevents similar bypasses via standard <script> or fetch() calls in the committed page.
Impact
This vulnerability enables cross-site tracking. Once a tracker is marked as ‘signed-in’ for the browser context, FedCM’s privacy protections are weakened. A malicious RP can subsequently call navigator.credentials.get() for that tracker, and the browser will proceed to fetch the tracker’s accounts endpoint with first-party cookies. This allows the tracker to correlate the user’s identity with their visit to the RP site.
Suggested Steps to Reproduce
Note: These steps are theoretical as our tooling cannot currently execute code to verify them end-to-end, but they trace the vulnerable code paths identified.
- Setup an RP server (
https://rp.test) and a tracker server (https://tracker.test). - Configure the RP to respond to a top-level navigation with a
103 Early Hintsresponse containing the header:Link: <https://tracker.test/setlogin>; rel=preload; as=script. - Configure the tracker server to respond to
/setloginwith the header:Set-Login: logged-in. - Navigate the browser to
https://rp.test. - Once the page commits, check
chrome://signin-internalsto confirm thathttps://tracker.testis now recorded as signed-in. - Execute the following JS on
https://rp.testto trigger a credentialed fetch to the tracker:navigator.credentials.get({ identity: { providers: [{ configURL: "https://tracker.test/fedcm.json" }] } }); - Observe the network request to
https://tracker.test’s accounts endpoint including cookies.
Suggested Fix
Update the validation in webid::SetIdpSigninStatus (content/browser/webid/webid_utils.cc) to handle top-level frames correctly. Similar to the renderer-side implementation in RequestService::SetIdpSigninStatus, the origin should likely be validated against the frame itself (or the hinted origin for Early Hints preloads) rather than only its parent, to prevent cross-origin preloads from bypassing the same-site restriction. For example, explicitly check if the destination is a subresource and enforce same-site checks against the main frame’s origin.
Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939
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.