Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in FedCM
DescriptionMissing authorization in FedCM
ComponentFedCM
Bug ClassLogic Error
Tracker517602176
Fix commitb90ba3f34c3d (chromium/src) +107/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

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
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 =
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 [email protected]

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:"[email protected]"});</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