Low watch CVSS 4.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in FedCM
DescriptionInsufficient validation of untrusted input in FedCM
ComponentFedCM
Sub-componentFedCM
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497427430
Fix commit5932bfd9d475 (chromium/src) +283/-32
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-04-10 (public git)
Disclosed2026-05-05
Patch gap25 days — fix public in source before the advisory

Background

FedCM login status / `Set-Login` header
an identity provider (IdP) can report its sign-in state to the browser via a Set-Login HTTP response header, which the browser records through blink::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.

Key insight
The sign-in-status relay conflated the load initiator with the resource origin, so a cross-origin subresource (e.g. an Early Hints preload) could set another origin’s FedCM login status; the fix threads an explicit initiator alongside idp_origin so the same-origin check can be enforced.

Attack Path

  1. Attacker page preloads a cross-origin IdP an RP/attacker page emits a 103 Early Hints Link: rel=preload pointing at a victim IdP URL, which the browser process begins loading as a subresource.
  2. 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.
  3. Status applied without initiator check pre-patch the callback used the single resource origin and forwarded it to blink::SetIdpSigninStatus, recording the IdP’s login status from a cross-origin context.
  4. Spoof FedCM login state the victim IdP’s sign-in status is set/altered without a same-origin initiator, which the browser test IdpSigninStatusBrowserTest confirms must be blocked; the fix threads initiator so this cross-origin set is rejected.

Impact Assessment

A page could cause a cross-origin subresource load (notably via 103 Early Hints) to set a FedCM IdP’s login status without a same-origin initiator, an input-validation gap in FedCM login-status handling; the precondition is the ability to trigger such a browser-side subresource load with a 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

FunctionChangeNotes
if
chrome/renderer/url_loader_throttle_provider_impl.cc
modified
IdpSigninStatusBrowserTest
content/browser/webid/idp_signin_status_browsertest.cc
modified
if
content/browser/webid/idp_signin_status_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
content/browser/webid/idp_signin_status_browsertest.cc
modified

Files Changed

  • chrome/renderer/url_loader_throttle_provider_impl.cc
  • content/browser/webid/idp_signin_status_browsertest.cc

Audit Directions

  • Browser-side header processing without initiator checks
    audit 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 effects
    review 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 conflation
    flag callbacks/APIs that carry a single origin where both initiator and resource origin are relevant; same-origin decisions must name which origin they compare.
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);
Loading diff…

Regression Test / PoC

shipped with the fix
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",
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • content/browser/loader/url_loader_throttles.cc
  • content/browser/loader/navigation_early_hints_manager.cc
  • content/browser/loader/navigation_url_loader_impl.cc
  • content/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.

  1. Setup an RP server (https://rp.test) and a tracker server (https://tracker.test).
  2. Configure the RP to respond to a top-level navigation with a 103 Early Hints response containing the header: Link: <https://tracker.test/setlogin>; rel=preload; as=script.
  3. Configure the tracker server to respond to /setlogin with the header: Set-Login: logged-in.
  4. Navigate the browser to https://rp.test.
  5. Once the page commits, check chrome://signin-internals to confirm that https://tracker.test is now recorded as signed-in.
  6. Execute the following JS on https://rp.test to trigger a credentialed fetch to the tracker:
    navigator.credentials.get({
      identity: {
        providers: [{
          configURL: "https://tracker.test/fedcm.json"
        }]
      }
    });
    
  7. 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.

View on issue tracker