CVE-2026-106289
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/web_contents/web_contents_impl.cc |
modified | |
ifcontent/browser/webid/request.cc |
modified | |
TEST_Fcontent/browser/webid/request_unittest.cc |
modified | |
BindLambdaForTestingcontent/browser/webid/webid_browsertest.cc |
modified |
Files Changed
content/browser/webid/webid_browsertest.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/web_contents/web_contents_impl.hcontent/browser/webid/request_unittest.cccontent/browser/renderer_host/render_frame_host_delegate.hcontent/browser/webid/request.cc
Patch
From 0ceb085176e5374d66ef537065c0dbe61da090b0 Mon Sep 17 00:00:00 2001 From: Yi Gu <[email protected]> Date: Wed, 09 Sep 2026 07:55:26 -0700 Subject: [PATCH] [AutoLogin] Only notify embedder when resolved IdP matches the request Fixed: 517700327 Change-Id: I9019e6a7c4d851bbc12b4b11ae2e84d39b5f4961 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8373921 Commit-Queue: Yi Gu <[email protected]> Reviewed-by: Avi Drissman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1694591} --- diff --git a/content/browser/renderer_host/render_frame_host_delegate.h b/content/browser/renderer_host/render_frame_host_delegate.h index 30b5a44..89f004917 100644 --- a/content/browser/renderer_host/render_frame_host_delegate.h +++ b/content/browser/renderer_host/render_frame_host_delegate.h @@ -243,7 +243,9 @@ RenderFrameHostImpl* render_frame_host) {} // Called when a federated login request completes. - virtual void OnFedCmFederatedLogin(webid::FederatedLoginResult result) {} + virtual void OnFedCmFederatedLogin( + webid::FederatedLoginResult result, + const std::optional<url::Origin>& idp_origin) {} // Binds a DisplayCutoutHost object associated to |render_frame_host|. virtual void BindDisplayCutoutHost( diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc index aa808c9..9d39839e 100644 --- a/content/browser/web_contents/web_contents_impl.cc +++ b/content/browser/web_contents/web_contents_impl.cc @@ -2266,7 +2266,8 @@ } void WebContentsImpl::OnFedCmFederatedLogin( - webid::FederatedLoginResult result) { + webid::FederatedLoginResult result, + const std::optional<url::Origin>& idp_origin) { observers_.NotifyObservers(&WebContentsObserver::OnFedCmFederatedLogin, result == webid::FederatedLoginResult::kSuccess); @@ -2278,7 +2279,10 @@ webid::FederatedEmbedderLoginRequest* embedder_login_request = webid::FederatedEmbedderLoginRequest::Get(this); - if (embedder_login_request) { + // Continue waiting if the resolved IdP isn't the same IdP from the embedder + // request. + if (embedder_login_request && idp_origin && + *idp_origin == embedder_login_request->idp_origin()) { embedder_login_request->OnFederatedResultReceived(result); } } diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h index 2926098..9c6e9c17 100644 --- a/content/browser/web_contents/web_contents_impl.h +++ b/content/browser/web_contents/web_contents_impl.h @@ -746,7 +746,9 @@ RenderFrameHostImpl* render_frame_host) override; void WebAuthnAssertionRequestSucceeded( RenderFrameHostImpl* render_frame_host) override; - void OnFedCmFederatedLogin(webid::FederatedLoginResult result) override; + void OnFedCmFederatedLogin( + webid::FederatedLoginResult result, + const std::optional<url::Origin>& idp_origin) override; void BindDisplayCutoutHost( RenderFrameHostImpl* render_frame_host, mojo::PendingAssociatedReceiver<blink::mojom::DisplayCutoutHost> receiver) diff --git a/content/browser/webid/request.cc b/content/browser/webid/request.cc index 6ba1d436..eed35db 100644 --- a/content/browser/webid/request.cc +++ b/content/browser/webid/request.cc @@ -1911,10 +1911,15 @@ // request, even if the callback is delayed. RecordMetricsAndConsoleError(result, token_status, selected_idp_config_url); + std::optional<url::Origin> idp_origin; + if (selected_idp_config_url) { + idp_origin = url::Origin::Create(*selected_idp_config_url); + } + RenderFrameHostImpl::From(&render_frame_host()) ->delegate() ->OnFedCmFederatedLogin( - FederatedRequestResultToFederatedLoginResult(result)); + FederatedRequestResultToFederatedLoginResult(result), idp_origin); if (token_received_callback_for_autofill_) { std::move(token_received_callback_for_autofill_) diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc index 02951b8..270dd0c 100644 --- a/content/browser/webid/request_unittest.cc +++ b/content/browser/webid/request_unittest.cc @@ -2138,6 +2138,26 @@ RunTest(kDefaultRequestParameters, kExpectationSuccess, kConfigurationValid); } +// Test that the FederatedEmbedderLoginRequest is not notified when the FedCM +// flow completes successfully for a different IdP. +TEST_F(RequestTest, + DoesNotNotifyFederatedEmbedderLoginRequestWithMismatchedIdp) { + url::Origin idp_origin = + url::Origin::Create(GURL("https://other-idp.example")); + std::string account_id = "account_id123"; + + base::MockCallback<base::OnceCallback<void(FederatedLoginResult)>> + result_callback; + // Since the result from the mismatched IdP is ignored, the embedder request + // only finishes when it times out during WaitForCurrentRequest(). + EXPECT_CALL(result_callback, Run(FederatedLoginResult::kTimeout)).Times(1); + + FederatedEmbedderLoginRequest::Set(web_contents(), idp_origin, account_id, + result_callback.Get()); + + RunTest(kDefaultRequestParameters, kExpectationSuccess, kConfigurationValid); +} + // Test successful well-known fetching. TEST_F(RequestTest, WellKnownSuccess) { // Use IdpNetworkRequestManagerParamChecker to validate passed-in parameters diff --git a/content/browser/webid/webid_browsertest.cc b/content/browser/webid/webid_browsertest.cc index 7bce6da..ad659b5 100644 --- a/content/browser/webid/webid_browsertest.cc +++ b/content/browser/webid/webid_browsertest.cc @@ -2752,4 +2752,76 @@ EXPECT_TRUE(entry->GetHasPostData()); } +IN_PROC_BROWSER_TEST_F(WebIdEmbedderInitiatedLoginTest, + MismatchedIdPInPopupDoesNotTriggerEmbedderCallback) { + idp_server()->SetConfigResponseDetails(BuildValidConfigDetails()); + + const url::Origin expected_idp = + url::Origin::Create(GURL("https://expected-idp.example")); + const url::Origin actual_idp = url::Origin::Create(GURL(BaseIdpUrl())); + EXPECT_NE(expected_idp, actual_idp); + + std::optional<webid::FederatedLoginResult> received_result; + webid::FederatedEmbedderLoginRequest::Set( + shell()->web_contents(), expected_idp, "expected_account", + base::BindLambdaForTesting([&](webid::FederatedLoginResult result) { + received_result = result; + })); + + ShellAddedObserver popup_observer; + GURL popup_url = https_server().GetURL(kRpHostName, "/title1.html"); + EXPECT_TRUE( + ExecJs(shell(), JsReplace("window.open($1, '_blank');", popup_url))); + Shell* popup = popup_observer.GetShell(); + ASSERT_TRUE(popup); + EXPECT_TRUE(WaitForLoadStop(popup->web_contents())); + + RenderFrameHost* opener_rfh = popup->web_contents()->GetOpener(); + ASSERT_TRUE(opener_rfh); + EXPECT_EQ(shell()->web_contents(), + WebContents::FromRenderFrameHost(opener_rfh)); + webid::FederatedEmbedderLoginRequest* req = + webid::FederatedEmbedderLoginRequest::Get(popup->web_contents()); + ASSERT_TRUE(req); + EXPECT_EQ(expected_idp, req->idp_origin()); + + EXPECT_EQ(std::string(kToken), EvalJs(popup, GetBasicRequestString())); + + EXPECT_FALSE(received_result.has_value()); + EXPECT_NE(nullptr, + webid::FederatedEmbedderLoginRequest::Get(shell()->web_contents())); +} + +IN_PROC_BROWSER_TEST_F(WebIdEmbedderInitiatedLoginTest, + MatchingIdPInPopupTriggersEmbedderCallback) { + idp_server()->SetConfigResponseDetails(BuildValidConfigDetails()); + + const url::Origin expected_idp = url::Origin::Create(GURL(BaseIdpUrl())); + + std::optional<webid::FederatedLoginResult> received_result; + base::RunLoop run_loop; + webid::FederatedEmbedderLoginRequest::Set( + shell()->web_contents(), expected_idp, "expected_account", + base::BindLambdaForTesting([&](webid::FederatedLoginResult result) { + received_result = result; + run_loop.Quit(); + })); + + ShellAddedObserver popup_observer; + GURL popup_url = https_server().GetURL(kRpHostName, "/title1.html"); + EXPECT_TRUE( + ExecJs(shell(), JsReplace("window.open($1, '_blank');", popup_url))); + Shell* popup = popup_observer.GetShell(); + ASSERT_TRUE(popup); + EXPECT_TRUE(WaitForLoadStop(popup->web_contents())); + + EXPECT_EQ(std::string(kToken), EvalJs(popup, GetBasicRequestString())); + + run_loop.Run(); + ASSERT_TRUE(received_result.has_value()); + EXPECT_EQ(webid::FederatedLoginResult::kSuccess, *received_result); + EXPECT_EQ(nullptr, + webid::FederatedEmbedderLoginRequest::Get(shell()->web_contents())); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 02951b8..270dd0c 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -2138,6 +2138,26 @@
RunTest(kDefaultRequestParameters, kExpectationSuccess, kConfigurationValid);
}
+// Test that the FederatedEmbedderLoginRequest is not notified when the FedCM
+// flow completes successfully for a different IdP.
+TEST_F(RequestTest,
+ DoesNotNotifyFederatedEmbedderLoginRequestWithMismatchedIdp) {
+ url::Origin idp_origin =
+ url::Origin::Create(GURL("https://other-idp.example"));
+ std::string account_id = "account_id123";
+
+ base::MockCallback<base::OnceCallback<void(FederatedLoginResult)>>
+ result_callback;
+ // Since the result from the mismatched IdP is ignored, the embedder request
+ // only finishes when it times out during WaitForCurrentRequest().
+ EXPECT_CALL(result_callback, Run(FederatedLoginResult::kTimeout)).Times(1);
+
+ FederatedEmbedderLoginRequest::Set(web_contents(), idp_origin, account_id,
+ result_callback.Get());
+
+ RunTest(kDefaultRequestParameters, kExpectationSuccess, kConfigurationValid);
+}
+
// Test successful well-known fetching.
TEST_F(RequestTest, WellKnownSuccess) {
// Use IdpNetworkRequestManagerParamChecker to validate passed-in parameters
diff --git a/content/browser/webid/webid_browsertest.cc b/content/browser/webid/webid_browsertest.cc
index 7bce6da..ad659b5 100644
--- a/content/browser/webid/webid_browsertest.cc
+++ b/content/browser/webid/webid_browsertest.cc
@@ -2752,4 +2752,76 @@
EXPECT_TRUE(entry->GetHasPostData());
}
+IN_PROC_BROWSER_TEST_F(WebIdEmbedderInitiatedLoginTest,
+ MismatchedIdPInPopupDoesNotTriggerEmbedderCallback) {
+ idp_server()->SetConfigResponseDetails(BuildValidConfigDetails());
+
+ const url::Origin expected_idp =
+ url::Origin::Create(GURL("https://expected-idp.example"));
+ const url::Origin actual_idp = url::Origin::Create(GURL(BaseIdpUrl()));
+ EXPECT_NE(expected_idp, actual_idp);
+
+ std::optional<webid::FederatedLoginResult> received_result;
+ webid::FederatedEmbedderLoginRequest::Set(
+ shell()->web_contents(), expected_idp, "expected_account",
+ base::BindLambdaForTesting([&](webid::FederatedLoginResult result) {
+ received_result = result;
+ }));
+
+ ShellAddedObserver popup_observer;
+ GURL popup_url = https_server().GetURL(kRpHostName, "/title1.html");
+ EXPECT_TRUE(
+ ExecJs(shell(), JsReplace("window.open($1, '_blank');", popup_url)));
+ Shell* popup = popup_observer.GetShell();
+ ASSERT_TRUE(popup);
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ RenderFrameHost* opener_rfh = popup->web_contents()->GetOpener();
+ ASSERT_TRUE(opener_rfh);
+ EXPECT_EQ(shell()->web_contents(),
+ WebContents::FromRenderFrameHost(opener_rfh));
+ webid::FederatedEmbedderLoginRequest* req =
+ webid::FederatedEmbedderLoginRequest::Get(popup->web_contents());
+ ASSERT_TRUE(req);
+ EXPECT_EQ(expected_idp, req->idp_origin());
+
+ EXPECT_EQ(std::string(kToken), EvalJs(popup, GetBasicRequestString()));
+
+ EXPECT_FALSE(received_result.has_value());
+ EXPECT_NE(nullptr,
+ webid::FederatedEmbedderLoginRequest::Get(shell()->web_contents()));
+}
+
+IN_PROC_BROWSER_TEST_F(WebIdEmbedderInitiatedLoginTest,
+ MatchingIdPInPopupTriggersEmbedderCallback) {
+ idp_server()->SetConfigResponseDetails(BuildValidConfigDetails());
+
+ const url::Origin expected_idp = url::Origin::Create(GURL(BaseIdpUrl()));
+
+ std::optional<webid::FederatedLoginResult> received_result;
+ base::RunLoop run_loop;
+ webid::FederatedEmbedderLoginRequest::Set(
+ shell()->web_contents(), expected_idp, "expected_account",
+ base::BindLambdaForTesting([&](webid::FederatedLoginResult result) {
+ received_result = result;
+ run_loop.Quit();
+ }));
+
+ ShellAddedObserver popup_observer;
+ GURL popup_url = https_server().GetURL(kRpHostName, "/title1.html");
+ EXPECT_TRUE(
+ ExecJs(shell(), JsReplace("window.open($1, '_blank');", popup_url)));
+ Shell* popup = popup_observer.GetShell();
+ ASSERT_TRUE(popup);
+ EXPECT_TRUE(WaitForLoadStop(popup->web_contents()));
+
+ EXPECT_EQ(std::string(kToken), EvalJs(popup, GetBasicRequestString()));
+
+ run_loop.Run();
+ ASSERT_TRUE(received_result.has_value());
+ EXPECT_EQ(webid::FederatedLoginResult::kSuccess, *received_result);
+ EXPECT_EQ(nullptr,
+ webid::FederatedEmbedderLoginRequest::Get(shell()->web_contents()));
+}
+
} // namespace content
Original Bug Report
FedCM completion bypasses IdP origin check in WebContentsImpl::OnFedCmFederatedLogin
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: A potential validation gap exists in the FedCM embedder callback path where WebContentsImpl::OnFedCmFederatedLogin unconditionally forwards login results to the opener-chain FederatedEmbedderLoginRequest. Because the completed Identity Provider (IdP) origin is discarded before reaching this sink, the browser cannot verify that the completing FedCM request matches the expected IdP. This could potentially allow an attacker-controlled popup to spoof a successful federated login or disrupt legitimate login attempts.
Affected files:
content/browser/web_contents/web_contents_impl.cccontent/browser/webid/request_service.cccontent/public/browser/webid/federated_embedder_login_request.hcontent/browser/webid/federated_embedder_login_request.cc
Estimated timestamp from git blame: 2026-03-04
Root Cause Analysis
WebContentsImpl::OnFedCmFederatedLogin serves as the RenderFrameHostDelegate sink for handling completed FedCM requests via RequestService::CompleteRequest. After notifying observers, it attempts to resolve any pending FederatedEmbedderLoginRequest by walking the window.opener chain of the WebContents via FederatedEmbedderLoginRequest::Get(this) and unconditionally invokes the callback:
void WebContentsImpl::OnFedCmFederatedLogin(
webid::FederatedLoginResult result) {
observers_.NotifyObservers(&WebContentsObserver::OnFedCmFederatedLogin,
result == webid::FederatedLoginResult::kSuccess);
if (IsBeingDestroyed()) {
return;
}
webid::FederatedEmbedderLoginRequest* embedder_login_request =
webid::FederatedEmbedderLoginRequest::Get(this);
if (embedder_login_request) {
embedder_login_request->OnFederatedResultReceived(result);
}
}
However, this function receives only the FederatedLoginResult status enum. The caller, RequestService::CompleteRequest, possesses the selected_idp_config_url but discards it before notifying the delegate:
RecordMetricsAndConsoleError(result, token_status, selected_idp_config_url);
RenderFrameHostImpl::From(&render_frame_host())
->delegate()
->OnFedCmFederatedLogin(
FederatedAuthRequestResultToFederatedLoginResult(result));
Consequently, WebContentsImpl::OnFedCmFederatedLogin lacks the necessary context to verify that the completed FedCM request matches the expected embedder_login_request->idp_origin(). Since Get() walks the window.opener chain, any popup opened from the Actor-Login tab can reach the Actor tab’s pending request.
While standard dialog-based flows trigger ShowAccountsDialog which explicitly validates the IDP origin and consumes the request on a mismatch (reporting kAccountNotLoggedIn), the auto-reauthn path bypasses ShowAccountsDialog entirely when exactly one returning account is available, routing instead to ShowVerifyingDialog which does not perform this validation check.
Potential Exploitation Path
(Note: These are potential steps, as our analysis is based on static code review and our tooling has not run code to produce a working proof of concept.)
- On the Actor tab, a legitimate federated login flow is initiated (e.g., to
https://accounts.google.com), setting a pendingFederatedEmbedderLoginRequestforhttps://accounts.google.comon the Actor tab’sWebContents. - An attacker-controlled page on the Actor tab uses
window.open()to launch a popup window. - The popup initiates a FedCM request to an attacker-controlled IDP (e.g.,
https://evil-idp.example). - The auto-reauthn flow triggers on the popup (since auto-reauthn is not blocked for the popup’s context), retrieves a token, and calls
CompleteRequestwithresult = kSuccess. CompleteRequestinvokesOnFedCmFederatedLogin(kSuccess)on the popup’sWebContentsImpl.OnFedCmFederatedLoginwalks the opener chain, locates the Actor tab’s pending request, and unconditionally invokes its callback withkSuccess—without verifying that the completed IdP was indeedhttps://accounts.google.com.- The embedder (e.g.,
ActorLoginSiwgController) processes the login as successful, potentially callingGrantPermission()to register persistent federated permissions forhttps://accounts.google.comdespite the actual completed login being tohttps://evil-idp.example.
Suggested Fix
Modify WebContentsImpl::OnFedCmFederatedLogin and the RenderFrameHostDelegate::OnFedCmFederatedLogin interface signature to accept both the FederatedLoginResult status enum and the selected IDP origin (const url::Origin& idp_origin).
Within OnFedCmFederatedLogin, verify that the completed IDP origin matches the expected idp_origin() of the retrieved FederatedEmbedderLoginRequest before calling OnFederatedResultReceived:
void WebContentsImpl::OnFedCmFederatedLogin(
webid::FederatedLoginResult result,
const url::Origin& idp_origin) {
...
webid::FederatedEmbedderLoginRequest* embedder_login_request =
webid::FederatedEmbedderLoginRequest::Get(this);
if (embedder_login_request) {
if (embedder_login_request->idp_origin() == idp_origin) {
embedder_login_request->OnFederatedResultReceived(result);
} else {
embedder_login_request->OnFederatedResultReceived(
webid::FederatedLoginResult::kAccountNotLoggedIn);
}
}
}
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.