CVE-2026-87622
Overview
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
Patch
From b90ba3f34c3d3f68790a7b2e7d5cbc63a3700d09 Mon Sep 17 00:00:00 2001 From: Nicolás Peña <[email protected]> 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 <[email protected]> Commit-Queue: Nicolás Peña <[email protected]> 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:"[email protected]"});</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.