Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FedCM
DescriptionMissing authorization in FedCM
ComponentFedCM
Sub-componentFedCM
Bug ClassAuthorization Error
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.
Tracker517602176
Fix commitb90ba3f34c3d (chromium/src) +107/-0
CWECWE-862
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-05-28 (per vendor advisory)
Fix landed2026-08-06 (public git)
Disclosed2026-09-08
Patch gap33 days — fix public in source before the advisory

Background

FedCM (Federated Credential Management)
a browser API that lets a page request or manage federated sign-in with an Identity Provider (IdP), mediated by the browser to avoid third-party cookies.
`RequestService::Disconnect`
the browser-side handler for the FedCM “disconnect” Mojo call, which tells an IdP to sever the association between a user account and a relying-party client.
Opaque origin / fenced frame / non-primary page
respectively an origin with no meaningful security principal (e.g. a sandboxed iframe or data: document), a frame isolated in a fenced-frame tree, and a page not currently the primary displayed page (e.g. prerendering or back/forward cache); FedCM is meant to be unavailable in all three.
`render_frame_host()`
accessor returning the RenderFrameHost bound to this RequestService, whose origin, fenced-frame status, and page lifecycle state are the basis for the added authorization checks.

Root Cause Analysis

The FedCM disconnect entry point RequestService::Disconnect proceeded straight to enforcing the identity-credentials-get Permissions Policy and issuing the disconnect flow without first verifying that the calling context was actually permitted to use FedCM. Every other FedCM Mojo entry point rejects calls originating from opaque origins, fenced-frame trees, and non-primary pages, but Disconnect omitted these gates, so a call from a sandboxed iframe, a fenced frame, or a prerendered/cached document reached the network path and sent a disconnect request to the IdP. This violated the invariant that FedCM operations only run in a top-level-equivalent, primary, non-opaque browsing context with a genuine security principal.

The fix inserts a guard at the top of the function that, if GetLastCommittedOrigin().opaque(), IsNestedWithinFencedFrame(), or !GetPage().IsPrimary() holds, runs the DisconnectCallback with blink::mojom::DisconnectStatus::kError and returns before any policy check or network request. Because the guard runs first, the disconnect never leaves the browser in a disallowed context, which the added tests confirm by asserting kError and a zero Blink.FedCm.Status.Disconnect histogram count.

Key insight
The single mistake was that RequestService::Disconnect skipped the context-authorization checks (opaque origin, fenced frame, primary page) that the other FedCM entry points already enforce; the fix restores parity by adding those three checks as an early-exit guard that returns kError before any work is done.

Attack Path

  1. Obtain a disallowed FedCM context An attacker page embeds a sandboxed (opaque-origin) iframe or a fenced frame, or has its document placed in a non-primary state such as prerendering or the back/forward cache.
  2. Reach the unchecked entry point From that context, script (or a compromised renderer) binds the FederatedRequestService and calls Disconnect with an attacker-chosen IdP config_url, client_id, and account_hint.
  3. Bypass the missing authorization gate Because pre-patch Disconnect had no opaque-origin/fenced-frame/primary-page check, execution falls through to the disconnect flow instead of being rejected as it would be for other FedCM calls.
  4. Trigger an unauthorized IdP disconnect request The browser sends the FedCM disconnect network request to the IdP for the specified account/client, effecting a CSRF-style state change (severing the account association) that the originating context should never have been able to initiate (inferred that this revokes/disconnects the account at the IdP, based on the commit’s “Disconnect CSRF bypass” description).

Impact Assessment

An attacker in a context that should not have FedCM access (a sandboxed iframe, fenced frame, or non-primary/prerendered page) could invoke the FedCM disconnect flow browser-side and cause an unauthorized disconnect network request to the IdP, a missing-authorization / CSRF issue rather than memory corruption. The action executes in the browser process on behalf of the victim’s session, with the precondition that the attacker controls or can script such a context and knows the target IdP config_url and client_id. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation past the disconnect request itself is (inferred).

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/webid/request_unittest.cc
modified
BindLambdaForTesting
content/browser/webid/request_unittest.cc
modified

Files Changed

  • content/browser/webid/request_service.cc
  • content/browser/webid/request_unittest.cc

Audit Directions

  • Entry-point parity across an API surface
    When one Mojo/IPC method in a feature enforces context checks (opaque origin, fenced frame, primary page, Permissions Policy), audit every sibling entry point of that same interface for the identical prelude, since a single omitted handler (here Disconnect) reintroduces the bypass.
  • State-changing calls treated as low-risk
    Handlers that “only” send a request or revoke/disconnect state, rather than returning data, still need the same origin/lifecycle authorization as the read/prompt paths; look for such write/side-effect methods that skip the guards their read counterparts have.
  • Non-primary and isolated contexts
    Search for handlers that use render_frame_host() without checking GetPage().IsPrimary(), IsNestedWithinFencedFrame(), or GetLastCommittedOrigin().opaque(), especially in features (WebID, credential management, payments) where prerendering, BFCache, or sandboxed/fenced frames must be excluded.
From b90ba3f34c3d3f68790a7b2e7d5cbc63a3700d09 Mon Sep 17 00:00:00 2001
From: Nicolás Peña <npm@chromium.org>
Date: Thu, 06 Aug 2026 08:42:23 -0700
Subject: [PATCH] [FedCM] Fix FedCM Disconnect CSRF bypass

RequestService::Disconnect omitted security checks to reject calls from:
1. Opaque origins (e.g. sandboxed iframes)
2. Fenced frame trees
3. Non-primary pages (e.g. prerendering)

This CL adds those checks to RequestService::Disconnect to return kError
immediately without sending network requests to the IdP, aligning it
with other FedCM Mojo entry points.

Bug: 517602176
Change-Id: Id64781887584629680968e9b295dc64c3bbb4833
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8203691
Reviewed-by: Yi Gu <yigu@chromium.org>
Commit-Queue: Nicolás Peña <npm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1675004}
---
diff --git a/content/browser/webid/request_service.cc b/content/browser/webid/request_service.cc
index dba89f5..8a0aefa 100644
--- a/content/browser/webid/request_service.cc
+++ b/content/browser/webid/request_service.cc
@@ -589,6 +589,13 @@
 void RequestService::Disconnect(
     blink::mojom::IdentityCredentialDisconnectOptionsPtr options,
     DisconnectCallback callback) {
+  if (render_frame_host().GetLastCommittedOrigin().opaque() ||
+      render_frame_host().IsNestedWithinFencedFrame() ||
+      !render_frame_host().GetPage().IsPrimary()) {
+    std::move(callback).Run(blink::mojom::DisconnectStatus::kError);
+    return;
+  }
+
   // Enforce identity-credentials-get Permissions Policy browser-side.
   // The renderer checks this, but a compromised renderer can bypass it.
   if (!render_frame_host().IsFeatureEnabled(
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 30e7e13..0c1818f 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -9187,6 +9187,106 @@
   run_loop.Run();
 }
 
+TEST_F(RequestTest, DisconnectFromOpaqueOrigin) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  static_cast<TestWebContents*>(web_contents())
+      ->NavigateAndCommit(GURL("data:text/html,hi"), ui::PAGE_TRANSITION_LINK);
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(main_test_rfh());
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
+TEST_F(RequestTest, DisconnectFromFencedFrame) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  RenderFrameHost* fenced_frame =
+      RenderFrameHostTester::For(main_test_rfh())->AppendFencedFrame();
+  ASSERT_TRUE(fenced_frame);
+
+  GURL fenced_frame_url = GURL("https://fencedframe.com");
+  std::unique_ptr<NavigationSimulator> navigation_simulator =
+      NavigationSimulator::CreateRendererInitiated(fenced_frame_url,
+                                                   fenced_frame);
+  navigation_simulator->Commit();
+  fenced_frame = navigation_simulator->GetFinalRenderFrameHost();
+  ASSERT_TRUE(fenced_frame);
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(fenced_frame);
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
+TEST_F(RequestTest, DisconnectFromNonPrimaryPage) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(main_test_rfh());
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  static_cast<RenderFrameHostImpl*>(main_test_rfh())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
 TEST_F(RequestTest, ResolveViaFederatedRequestService) {
   mojo::Remote<FederatedRequestService> federated_request_service;
   RequestService* service =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 30e7e13..0c1818f 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -9187,6 +9187,106 @@
   run_loop.Run();
 }
 
+TEST_F(RequestTest, DisconnectFromOpaqueOrigin) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  static_cast<TestWebContents*>(web_contents())
+      ->NavigateAndCommit(GURL("data:text/html,hi"), ui::PAGE_TRANSITION_LINK);
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(main_test_rfh());
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
+TEST_F(RequestTest, DisconnectFromFencedFrame) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  RenderFrameHost* fenced_frame =
+      RenderFrameHostTester::For(main_test_rfh())->AppendFencedFrame();
+  ASSERT_TRUE(fenced_frame);
+
+  GURL fenced_frame_url = GURL("https://fencedframe.com");
+  std::unique_ptr<NavigationSimulator> navigation_simulator =
+      NavigationSimulator::CreateRendererInitiated(fenced_frame_url,
+                                                   fenced_frame);
+  navigation_simulator->Commit();
+  fenced_frame = navigation_simulator->GetFinalRenderFrameHost();
+  ASSERT_TRUE(fenced_frame);
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(fenced_frame);
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
+TEST_F(RequestTest, DisconnectFromNonPrimaryPage) {
+  base::HistogramTester histogram_tester;
+  ResetAndDeleteRequest();
+
+  mojo::Remote<FederatedRequestService> federated_request_service;
+  RequestService* service =
+      RequestService::GetOrCreateForCurrentDocument(main_test_rfh());
+  service->BindFederatedRequestService(
+      federated_request_service.BindNewPipeAndPassReceiver());
+
+  static_cast<RenderFrameHostImpl*>(main_test_rfh())
+      ->SetLifecycleState(
+          RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+
+  auto options = blink::mojom::IdentityCredentialDisconnectOptions::New();
+  options->config = blink::mojom::IdentityProviderConfig::New();
+  options->config->config_url = GURL(kProviderUrlFull);
+  options->config->client_id = kClientId;
+  options->account_hint = "hint";
+
+  base::RunLoop run_loop;
+  federated_request_service->Disconnect(
+      std::move(options),
+      base::BindLambdaForTesting([&](blink::mojom::DisconnectStatus status) {
+        EXPECT_EQ(blink::mojom::DisconnectStatus::kError, status);
+        run_loop.Quit();
+      }));
+  run_loop.Run();
+  histogram_tester.ExpectTotalCount("Blink.FedCm.Status.Disconnect", 0);
+}
+
 TEST_F(RequestTest, ResolveViaFederatedRequestService) {
   mojo::Remote<FederatedRequestService> federated_request_service;
   RequestService* service =
Loading diff…

Original Bug Report

reported by vm...@google.com

FedCM Disconnect CSRF bypass via missing opaque-origin and fenced-frame gates

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The FedCM RequestService::Disconnect Mojo entry point lacks critical validation to reject opaque-origin and fenced-frame callers. This potentially allows a sandboxed iframe with delegated permissions or a frame inside a fenced frame to initiate credentialed disconnect requests with an Origin header serialized to ’null’. This bypasses standard origin boundary checks and cross-site request forgery protections at the Identity Provider.

Affected files:

  • content/browser/webid/request_service.cc
  • third_party/blink/renderer/modules/credentialmanagement/identity_credential.cc
  • content/browser/webid/disconnect_request.cc

Estimated timestamp from git blame: 2023-10-26

Potential Security Boundary Bypass in FedCM Disconnect

We have identified a potential security vulnerability in the Federated Credential Management (FedCM) Disconnect API implementation, where the browser process fails to validate and reject opaque-origin and fenced-frame callers. This could allow an untrusted or sandboxed context to issue browser-mediated, credentialed POST requests to an Identity Provider’s (IdP) disconnect endpoint with an Origin: null header.

Note: The following analysis and steps are potential/suggested based on static code review; our automated environment does not currently have the capability to run code or execute a live proof-of-concept.


Root Cause Analysis

1. Lack of Browser-Side Validation

In content/browser/webid/request_service.cc at line 2836, RequestService::Disconnect contains no checks to ensure that the calling frame is not an opaque origin and is not nested within a fenced frame. By contrast, sibling entry points like RequestToken (line 291) call ShouldTerminateRequest() which rejects fenced frames:

if (render_frame_host().IsNestedWithinFencedFrame()) {
  ReportBadMessage("FedCM should not be allowed in fenced frame trees.");
  return true;
}

And RequestToken also explicitly rejects opaque origins (line 448):

if (origin().opaque()) {
  CompleteRequestWithError(...);
  return;
}

In the Disconnect flow, these checks are absent. The DisconnectRequest constructor in content/browser/webid/disconnect_request.cc only has a release-stripped DCHECK for the main frame being in the primary main frame:

RenderFrameHost* main_frame = render_frame_host->GetMainFrame();
DCHECK(main_frame->IsInPrimaryMainFrame());
embedding_origin_ = main_frame->GetLastCommittedOrigin();

In production builds, this DCHECK compiles out, allowing fenced frames to proceed.

2. Lack of Renderer-Side Validation

In third_party/blink/renderer/modules/credentialmanagement/identity_credential.cc at line 107, IdentityCredential::disconnect is implemented as a static method and bypasses the standard credential helper checks (such as CheckGenericSecurityRequirementsForCredentialsContainerRequest()), which would normally reject fenced-frame callers.

3. Bypassing Origin Check via Permissions Policy and Third-Party Cookies

A sandboxed iframe declared with sandbox="allow-scripts" allow="identity-credentials-get" is allowed to run the API because the feature is delegated. Since the iframe has an opaque origin, the requester’s origin is captured as opaque. In content/browser/webid/disconnect_request.cc at line 102, the browser calls HasSharingPermissionOrIdpHasThirdPartyCookiesAccess. If the embedding origin (https://attacker.example) has third-party cookie access to the IdP, this check returns true (via api_permission_delegate->HasThirdPartyCookiesAccess in webid_utils.cc line 438), completely bypassing the sharing permission check for the requester’s opaque origin.

4. Serialization of Origin to “null”

When IdpNetworkRequestManager::SendDisconnectRequest prepares the fetch in content/browser/webid/idp_network_request_manager.cc (line 1317), it creates a credentialed resource request. The NetworkRequestManager::CreateCredentialedResourceRequest in content/browser/webid/network_request_manager.cc (line 313) sets the Origin header to relying_party_origin_.Serialize(). For an opaque origin, this serializes to the literal string "null". This credentialed CORS request is then sent to the IdP carrying the user’s cookies.


Potential Exploitation Scenario

  1. A user is logged in to a FedCM-supporting IdP (e.g., https://idp.example) and has active first-party session cookies.
  2. The user visits an attacker-controlled page https://attacker.example.
  3. The attacker page hosts a sandboxed iframe with delegated permissions:
    <iframe sandbox="allow-scripts" allow="identity-credentials-get"
            srcdoc='<script>IdentityCredential.disconnect({configURL:"https://idp.example/fedcm/config.json",clientId:"target-rp-client-id",accountHint:"victim@example.com"});</script>'></iframe>
    
  4. Both the renderer and browser-side permissions policy checks pass because the feature is delegated.
  5. The browser initiates the FedCM disconnect flow. Since the embedder is https://attacker.example, the third-party cookie check passes, and the browser issues a credentialed POST to the IdP’s disconnect endpoint with Origin: null and the victim’s session cookies.
  6. If the IdP does not explicitly reject requests containing Origin: null, it will process the request and silently disconnect/revoke the victim’s account linkage.

Suggested Fix

Enforce browser-side validation in RequestService::Disconnect in content/browser/webid/request_service.cc to reject both opaque origins and fenced-frame callers:

void RequestService::Disconnect(...) {
  if (render_frame_host().IsNestedWithinFencedFrame()) {
    ReportBadMessage("FedCM disconnect is not allowed in fenced frame trees.");
    return;
  }
  if (origin().opaque()) {
    ReportBadMessage("FedCM disconnect is not allowed from opaque origins.");
    return;
  }
  ...
}

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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