Chrome · FedCM
CVE-2026-79078
UAF in FedCM
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/webid/accounts_fetcher.cc |
modified | |
TEST_Fcontent/browser/webid/accounts_fetcher_unittest.cc |
modified | |
ifcontent/browser/webid/request.cc |
modified | |
TEST_Fcontent/browser/webid/request_unittest.cc |
modified |
Files Changed
content/browser/webid/accounts_fetcher.cccontent/browser/webid/accounts_fetcher_unittest.cccontent/browser/webid/request.cccontent/browser/webid/request_unittest.cc
Patch
From 7cc254e978dd7699145eeda120873c03e9abf762 Mon Sep 17 00:00:00 2001
From: Nicolás Peña <npm@chromium.org>
Date: Wed, 19 Aug 2026 14:14:47 -0700
Subject: [PATCH] [FedCM] Fix UAF when double clicking user other account
When the user clicks "Use a different account" in a FedCM dialog,
Request::LoginToIdP registers Request as an IdpSigninStatusObserver
and opens the IdP login popup.
If the user refocuses the FedCM dialog and clicks on the "Use a
different account" again while the accounts fetch is in flight, we
previously re-registered Request as an observer. When the pending
response arrived, updating the sign-in status would synchronously
notify observers. Because Request was observing again, it would start a
new accounts fetch, deleting the current AccountsFetcher while on
OnAccountsResponseReceived, causing a UAF.
This CL fixes this by:
1. Checking if the login popup is already open, and avoiding registering
as an observer again.
2. Adding a WeakPtr guard in OnAccountsResponseReceived so it returns
safely if destroyed during synchronous callbacks.
Fixed: 548340637
Change-Id: Ie7f7e5cc43809c6b1143d99d366fb1c52c480189
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8270646
Reviewed-by: Yi Gu <yigu@chromium.org>
Commit-Queue: Nicolás Peña <npm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1682618}
---
diff --git a/content/browser/webid/accounts_fetcher.cc b/content/browser/webid/accounts_fetcher.cc
index dc77cd3b..09b3907 100644
--- a/content/browser/webid/accounts_fetcher.cc
+++ b/content/browser/webid/accounts_fetcher.cc
@@ -377,9 +377,13 @@
const std::optional<bool> old_idp_signin_status =
permission_delegate_->GetIdpSigninStatus(
url::Origin::Create(idp_config_url));
+ base::WeakPtr<AccountsFetcher> weak_this = weak_ptr_factory_.GetWeakPtr();
UpdateIdpSigninStatusForAccountsEndpointResponse(
idp_config_url, status, idp_info->has_failing_idp_signin_status,
permission_delegate_);
+ if (!weak_this) {
+ return;
+ }
if (status.parse_status != ParseStatus::kSuccess) {
if (IsFedCmNativeIdPsEnabled() && network_manager_) {
diff --git a/content/browser/webid/accounts_fetcher_unittest.cc b/content/browser/webid/accounts_fetcher_unittest.cc
index 26669da7..0e3cdbd 100644
--- a/content/browser/webid/accounts_fetcher_unittest.cc
+++ b/content/browser/webid/accounts_fetcher_unittest.cc
@@ -478,4 +478,92 @@
SetBrowserClientForTesting(original_client);
}
+// Verifies that if AccountsFetcher is destroyed re-entrantly during
+// SetIdpSigninStatus (e.g. via observer callbacks), OnAccountsResponseReceived
+// returns cleanly without use-after-free.
+TEST_F(AccountsFetcherTest, ReentrantDestructionInAccountsResponse) {
+ const GURL kIdpConfigUrl("https://idp.example/fedcm.json");
+ const GURL kAccountsEndpoint("https://idp.example/accounts.json");
+ const GURL kTokenEndpoint("https://idp.example/token.json");
+
+ auto network_manager =
+ std::make_unique<StrictMock<MockIdpNetworkRequestManager>>();
+
+ IdpNetworkRequestManager::AccountsRequestCallback accounts_callback;
+ EXPECT_CALL(*network_manager, SendAccountsRequest)
+ .WillOnce(
+ WithArg<2>([&accounts_callback](
+ IdpNetworkRequestManager::AccountsRequestCallback cb) {
+ accounts_callback = std::move(cb);
+ return true;
+ }));
+
+ std::unique_ptr<AccountsFetcher> fetcher = std::make_unique<AccountsFetcher>(
+ *main_rfh(), network_manager.get(), api_permission_delegate_.get(),
+ permission_delegate_.get(),
+ AccountsFetcher::FedCmFetchingParams(
+ blink::mojom::RpMode::kPassive, /*icon_ideal_size=*/0,
+ /*icon_minimum_size=*/0,
+ password_manager::CredentialMediationRequirement::kOptional),
+ base::BindLambdaForTesting(
+ [](base::TimeTicks, std::vector<AccountsFetcher::Result>) {
+ FAIL() << "Callback should not be called after destruction";
+ }));
+
+ auto config = blink::mojom::IdentityProviderConfig::New();
+ config->config_url = kIdpConfigUrl;
+
+ auto options = blink::mojom::IdentityProviderRequestOptions::New(
+ std::move(config), "nonce", /*login_hint=*/"", /*domain_hint=*/"",
+ /*fields=*/std::nullopt, /*params_json=*/std::nullopt,
+ /*format=*/std::nullopt);
+
+ IdpNetworkRequestManager::Endpoints endpoints;
+ endpoints.accounts = kAccountsEndpoint;
+ endpoints.token = kTokenEndpoint;
+
+ auto idp_info = std::make_unique<IdentityProviderInfo>(
+ options, endpoints, IdentityProviderMetadata(),
+ blink::mojom::RpContext::kSignIn, blink::mojom::RpMode::kPassive,
+ /*format=*/std::nullopt);
+
+ std::vector<std::unique_ptr<IdentityProviderInfo>> cached_idp_infos;
+ cached_idp_infos.push_back(std::move(idp_info));
+
+ base::flat_map<GURL, AccountsFetcher::IdentityProviderGetInfo>
+ token_request_get_infos;
+ token_request_get_infos.emplace(
+ kIdpConfigUrl,
+ AccountsFetcher::IdentityProviderGetInfo(
+ options.Clone(), blink::mojom::RpContext::kSignIn,
+ blink::mojom::RpMode::kPassive, /*format=*/std::nullopt));
+
+ fetcher->FetchAccountsForIdps(
+ std::move(cached_idp_infos), token_request_get_infos, metrics_.get(),
+ url::Origin::Create(GURL("https://rp.example")), base::DoNothing());
+
+ EXPECT_CALL(*permission_delegate_, SetIdpSigninStatus)
+ .WillOnce(
+ [&fetcher](const url::Origin&, bool,
+ base::optional_ref<
+ const blink::common::webid::LoginStatusOptions>) {
+ fetcher.reset();
+ });
+
+ IdpNetworkRequestManager::AccountsResponse accounts_response;
+ accounts_response.accounts.push_back(
+ base::MakeRefCounted<IdentityRequestAccount>(
+ "123", "user@example.com", "User Name", "user@example.com",
+ "User Name", "User", GURL(), /*phone=*/"", /*username=*/"",
+ /*potentially_approved_site_hashes=*/std::vector<std::string>(),
+ /*login_hints=*/std::vector<std::string>(),
+ /*domain_hints=*/std::vector<std::string>(),
+ /*labels=*/std::vector<std::string>()));
+
+ ASSERT_TRUE(accounts_callback);
+ std::move(accounts_callback)
+ .Run({ParseStatus::kSuccess, net::HTTP_OK}, std::move(accounts_response));
+ EXPECT_FALSE(fetcher);
+}
+
} // namespace content::webid
diff --git a/content/browser/webid/request.cc b/content/browser/webid/request.cc
index 8ecd8fe..548a89ad 100644
--- a/content/browser/webid/request.cc
+++ b/content/browser/webid/request.cc
@@ -1498,16 +1498,18 @@
// the popup window is open. When using the active flow the dialog may
// still be up in some cases, but we do not expect that browser automation
// needs to interact with the account chooser in this case.
- if (dialog_type_ != DialogType::kNone) {
+ if (dialog_type_ != DialogType::kNone && dialog_type_ != dialog_type) {
// This call ensures that we send a dialogClosed event if an account
// chooser or mismatch dialog is open.
devtools_instrumentation::DidCloseFedCmDialog(render_frame_host());
}
// TODO(crbug.com/336815315): Should we notify browser automation of this
// dialog?
+ if (dialog_type_ != dialog_type) {
+ UMA_HISTOGRAM_ENUMERATION("Blink.FedCm.Popup.DialogType", dialog_type);
+ }
dialog_type_ = dialog_type;
config_url_ = idp_config_url;
- UMA_HISTOGRAM_ENUMERATION("Blink.FedCm.Popup.DialogType", dialog_type_);
auto create_registry_async = [](base::WeakPtr<Request> weak_this,
const GURL& idp_config_url,
@@ -2440,6 +2442,12 @@
// needed.
MaybeAppendQueryParameters(it->second, &login_url);
}
+
+ if (dialog_type_ == DialogType::kLoginToIdpPopup) {
+ ShowModalDialog(DialogType::kLoginToIdpPopup, idp_config_url, login_url);
+ return;
+ }
+
permission_delegate()->AddIdpSigninStatusObserver(this);
account_ids_before_login_.clear();
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 775d244..90795857 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -9789,4 +9789,46 @@
}
}
+// Verifies that calling LoginToIdP when the login popup is already open
+// refocuses the popup without re-registering the IdpSigninStatus observer or
+// resetting state, and that query parameters are preserved.
+TEST_F(RequestTest, LoginToIdPRefocusesWhenPopupAlreadyOpen) {
+ RequestParameters parameters = kDefaultRequestParameters;
+ parameters.identity_providers[0].domain_hint = kDomainHint;
+
+ test_permission_delegate_
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/content/browser/webid/accounts_fetcher_unittest.cc b/content/browser/webid/accounts_fetcher_unittest.cc
index 26669da7..0e3cdbd 100644
--- a/content/browser/webid/accounts_fetcher_unittest.cc
+++ b/content/browser/webid/accounts_fetcher_unittest.cc
@@ -478,4 +478,92 @@
SetBrowserClientForTesting(original_client);
}
+// Verifies that if AccountsFetcher is destroyed re-entrantly during
+// SetIdpSigninStatus (e.g. via observer callbacks), OnAccountsResponseReceived
+// returns cleanly without use-after-free.
+TEST_F(AccountsFetcherTest, ReentrantDestructionInAccountsResponse) {
+ const GURL kIdpConfigUrl("https://idp.example/fedcm.json");
+ const GURL kAccountsEndpoint("https://idp.example/accounts.json");
+ const GURL kTokenEndpoint("https://idp.example/token.json");
+
+ auto network_manager =
+ std::make_unique<StrictMock<MockIdpNetworkRequestManager>>();
+
+ IdpNetworkRequestManager::AccountsRequestCallback accounts_callback;
+ EXPECT_CALL(*network_manager, SendAccountsRequest)
+ .WillOnce(
+ WithArg<2>([&accounts_callback](
+ IdpNetworkRequestManager::AccountsRequestCallback cb) {
+ accounts_callback = std::move(cb);
+ return true;
+ }));
+
+ std::unique_ptr<AccountsFetcher> fetcher = std::make_unique<AccountsFetcher>(
+ *main_rfh(), network_manager.get(), api_permission_delegate_.get(),
+ permission_delegate_.get(),
+ AccountsFetcher::FedCmFetchingParams(
+ blink::mojom::RpMode::kPassive, /*icon_ideal_size=*/0,
+ /*icon_minimum_size=*/0,
+ password_manager::CredentialMediationRequirement::kOptional),
+ base::BindLambdaForTesting(
+ [](base::TimeTicks, std::vector<AccountsFetcher::Result>) {
+ FAIL() << "Callback should not be called after destruction";
+ }));
+
+ auto config = blink::mojom::IdentityProviderConfig::New();
+ config->config_url = kIdpConfigUrl;
+
+ auto options = blink::mojom::IdentityProviderRequestOptions::New(
+ std::move(config), "nonce", /*login_hint=*/"", /*domain_hint=*/"",
+ /*fields=*/std::nullopt, /*params_json=*/std::nullopt,
+ /*format=*/std::nullopt);
+
+ IdpNetworkRequestManager::Endpoints endpoints;
+ endpoints.accounts = kAccountsEndpoint;
+ endpoints.token = kTokenEndpoint;
+
+ auto idp_info = std::make_unique<IdentityProviderInfo>(
+ options, endpoints, IdentityProviderMetadata(),
+ blink::mojom::RpContext::kSignIn, blink::mojom::RpMode::kPassive,
+ /*format=*/std::nullopt);
+
+ std::vector<std::unique_ptr<IdentityProviderInfo>> cached_idp_infos;
+ cached_idp_infos.push_back(std::move(idp_info));
+
+ base::flat_map<GURL, AccountsFetcher::IdentityProviderGetInfo>
+ token_request_get_infos;
+ token_request_get_infos.emplace(
+ kIdpConfigUrl,
+ AccountsFetcher::IdentityProviderGetInfo(
+ options.Clone(), blink::mojom::RpContext::kSignIn,
+ blink::mojom::RpMode::kPassive, /*format=*/std::nullopt));
+
+ fetcher->FetchAccountsForIdps(
+ std::move(cached_idp_infos), token_request_get_infos, metrics_.get(),
+ url::Origin::Create(GURL("https://rp.example")), base::DoNothing());
+
+ EXPECT_CALL(*permission_delegate_, SetIdpSigninStatus)
+ .WillOnce(
+ [&fetcher](const url::Origin&, bool,
+ base::optional_ref<
+ const blink::common::webid::LoginStatusOptions>) {
+ fetcher.reset();
+ });
+
+ IdpNetworkRequestManager::AccountsResponse accounts_response;
+ accounts_response.accounts.push_back(
+ base::MakeRefCounted<IdentityRequestAccount>(
+ "123", "user@example.com", "User Name", "user@example.com",
+ "User Name", "User", GURL(), /*phone=*/"", /*username=*/"",
+ /*potentially_approved_site_hashes=*/std::vector<std::string>(),
+ /*login_hints=*/std::vector<std::string>(),
+ /*domain_hints=*/std::vector<std::string>(),
+ /*labels=*/std::vector<std::string>()));
+
+ ASSERT_TRUE(accounts_callback);
+ std::move(accounts_callback)
+ .Run({ParseStatus::kSuccess, net::HTTP_OK}, std::move(accounts_response));
+ EXPECT_FALSE(fetcher);
+}
+
} // namespace content::webid
diff --git a/content/browser/webid/request_unittest.cc b/content/browser/webid/request_unittest.cc
index 775d244..90795857 100644
--- a/content/browser/webid/request_unittest.cc
+++ b/content/browser/webid/request_unittest.cc
@@ -9789,4 +9789,46 @@
}
}
+// Verifies that calling LoginToIdP when the login popup is already open
+// refocuses the popup without re-registering the IdpSigninStatus observer or
+// resetting state, and that query parameters are preserved.
+TEST_F(RequestTest, LoginToIdPRefocusesWhenPopupAlreadyOpen) {
+ RequestParameters parameters = kDefaultRequestParameters;
+ parameters.identity_providers[0].domain_hint = kDomainHint;
+
+ test_permission_delegate_
+ ->idp_signin_statuses_[OriginFromString(kProviderUrlFull)] = true;
+
+ auto dialog_controller =
+ std::make_unique<TestDialogController>(kConfigurationValid);
+ base::WeakPtr<TestDialogController> weak_dialog_controller =
+ dialog_controller->AsWeakPtr();
+ SetDialogController(std::move(dialog_controller));
+
+ std::string expected_url =
+ std::string(kIdpLoginUrl) + "?domain_hint=domain%40corp.com";
+ std::unique_ptr<WebContents> modal(CreateTestWebContents());
+ EXPECT_CALL(*weak_dialog_controller,
+ ShowModalDialog(GURL(expected_url), _, _, _, _))
+ .Times(2)
+ .WillRepeatedly(::testing::Return(modal.get()));
+
+ // AddIdpSigninStatusObserver should only be called once when opening the
+ // popup the first time, and NOT again when LoginToIdP is called while the
+ // popup is already open.
+ EXPECT_CALL(*test_permission_delegate_, AddIdpSigninStatusObserver).Times(1);
+
+ RunDontWaitForCallback(parameters, kConfigurationValid);
+ EXPECT_TRUE(did_show_idp_signin_status_mismatch_dialog());
+
+ // First LoginToIdP call: opens the popup, adds observer, sets dialog_type_.
+ SimulateLoginToIdP();
+ EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup);
+
+ // Second LoginToIdP call while popup is already open: refocuses the popup
+ // without re-registering observer or resetting state.
+ SimulateLoginToIdP();
+ EXPECT_EQ(request_->GetDialogType(), Request::DialogType::kLoginToIdpPopup);
+}
+
} // namespace content::webid
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page