CVE-2026-8013
Overview
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
Patch
From 5932bfd9d475fb2d7f0197c725412ac4f98281de Mon Sep 17 00:00:00 2001
From: Christian Biesinger <cbiesinger@chromium.org>
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 <npm@chromium.org>
Reviewed-by: Colin Blundell <blundell@chromium.org>
Reviewed-by: Peter Kvitek <kvitekp@chromium.org>
Reviewed-by: Camille Lamy <clamy@chromium.org>
Commit-Queue: Christian Biesinger <cbiesinger@chromium.org>
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.