CVE-2026-87622
Overview
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
RenderFrameHostbound to thisRequestService, 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.
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
- 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.
- Reach the unchecked entry point
From that context, script (or a compromised renderer) binds the
FederatedRequestServiceand callsDisconnectwith an attacker-chosen IdPconfig_url,client_id, andaccount_hint. - Bypass the missing authorization gate
Because pre-patch
Disconnecthad 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. - 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
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
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/webid/request_unittest.cc |
modified | |
BindLambdaForTestingcontent/browser/webid/request_unittest.cc |
modified |
Files Changed
content/browser/webid/request_service.cccontent/browser/webid/request_unittest.cc
Audit Directions
- Entry-point parity across an API surfaceWhen 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-riskHandlers 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 contextsSearch for handlers that use
render_frame_host()without checkingGetPage().IsPrimary(),IsNestedWithinFencedFrame(), orGetLastCommittedOrigin().opaque(), especially in features (WebID, credential management, payments) where prerendering, BFCache, or sandboxed/fenced frames must be excluded.
Patch
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 =
Regression Test / PoC
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 =
Original Bug Report
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.ccthird_party/blink/renderer/modules/credentialmanagement/identity_credential.cccontent/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
- A user is logged in to a FedCM-supporting IdP (e.g.,
https://idp.example) and has active first-party session cookies. - The user visits an attacker-controlled page
https://attacker.example. - 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> - Both the renderer and browser-side permissions policy checks pass because the feature is delegated.
- 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 credentialedPOSTto the IdP’s disconnect endpoint withOrigin: nulland the victim’s session cookies. - 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.