CVE-2026-87462
Overview
Background
- FedCM
- the Federated Credential Management browser API, which shows a browser-controlled account-selection dialog through which the user consents to sharing an identity with a relying party.
- `FedCmAccountSelectionView`
- the desktop views implementation that owns the FedCM dialog widget (
dialog_widget_) and drives account selection. - `InputEventActivationProtector`
- a views helper that rejects input events arriving too soon after a UI becomes visible (a ~500ms cooldown), defeating clickjacking / tapjacking where a click lands on a surface that just appeared; it is re-armed by calling
VisibilityChanged(true). - `TabDialogManager`
- the tab-scoped dialog host that can hide and re-show the FedCM widget (e.g., on tab switch or window resize) without going back through the view’s own show path.
Root Cause Analysis
FedCmAccountSelectionView armed the InputEventActivationProtector by calling input_protector_->VisibilityChanged(true) only on its initial show path (before handing the dialog to TabDialogManager::Params) and reset it with VisibilityChanged(false) on hide. When TabDialogManager re-shows an already-created dialog — for example switching back to the tab, or resizing the window so a bubble dialog that was hidden can fit again — it skips the view’s ShowDialogWidget() path entirely, so VisibilityChanged(true) was never re-invoked and the protector’s cooldown was never restarted. Consequently a click delivered immediately after the dialog re-appeared was accepted by OnAccountSelected instead of being suppressed, so a user could be tricked into confirming an identity choice via an unintended rapid click on the freshly re-shown UI.
The fix makes FedCmAccountSelectionView a views::WidgetObserver, observes dialog_widget_ via dialog_widget_observation_, and routes every widget visibility transition through the new OnWidgetVisibilityChanged, which calls input_protector_->VisibilityChanged(visible) — so the protector is re-armed on every re-show regardless of code path. The unconditional VisibilityChanged(true) on the show path and the VisibilityChanged(false) in the hide path are removed because the observer now covers both.
TabDialogManager left the protector un-armed; the fix binds re-arming to the widget’s real visibility transitions through views::WidgetObserver::OnWidgetVisibilityChanged.Attack Path
- Show the FedCM dialog
A relying party invokes FedCM so the account-selection dialog appears in a tab, managed by
TabDialogManager; the initialInputEventActivationProtectorcooldown elapses. - Hide then re-show without the view's show path
The dialog is hidden and later re-shown by
TabDialogManager— e.g., the victim switches away and back to the tab, or a bubble dialog is hidden by a window resize and re-shown when it fits again — which skipsShowDialogWidget()and never re-arms the protector. - Land an immediate click on the re-shown UI
Because the protector was not re-armed, an input event arriving right as the dialog re-appears is accepted rather than rejected; an attacker who has baited the user to click at that moment (e.g., timing the re-show under the cursor) has the click routed to
OnAccountSelected. - Unintended account confirmation The accepted click confirms an account/identity selection the user did not deliberately make, a UI-misrepresentation / spoofing outcome on the FedCM consent surface.
Impact Assessment
TabDialogManager (tab switch, or resize for bubble dialogs) and that the attacker can induce a click at the moment of re-show. The advisory classifies it as UI misrepresentation in FedCM and does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc |
modified | |
FedCmAccountSelectionViewDesktopClickProtectionTestchrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc |
modified | |
TEST_Fchrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc |
modified |
Files Changed
chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.ccchrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.hchrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc
Audit Directions
- Cooldown/anti-clickjacking tied to a single code pathAudit other uses of
InputEventActivationProtector(and similar timing guards) that arm on an explicit show call rather than on actual widget visibility, especially UIs that can be hidden and re-shown by a manager likeTabDialogManager. - Re-show paths that bypass the view's own show logicLook for dialogs/bubbles whose visibility can change via tab activation, window resize, occlusion, or picture-in-picture without re-running their initialization, and verify security-relevant state is re-established on each transition.
- Consent surfaces requiring deliberate interactionReview other security-decision dialogs (permissions, payments, credential sharing) to confirm their input protection re-arms on every re-show, not only first display.
Patch
From 20281770dded5b0fbce9474da212dc84b8c41756 Mon Sep 17 00:00:00 2001 From: Yi Gu <[email protected]> Date: Wed, 12 Aug 2026 09:17:33 -0700 Subject: [PATCH] [FedCM] Observe dialog widget visibility to update input protector When the FedCM dialog is managed by TabDialogManager, re-showing it (e.g., switching back to the tab or resizing the window) skips ShowDialogWidget() and does not re-arm the InputEventActivationProtector. This could allow unintended rapid clicks on the re-shown UI. This CL makes FedCmAccountSelectionView observe the dialog widget's visibility via views::WidgetObserver, ensuring InputEventActivationProtector::VisibilityChanged() is invoked on every visibility transition. Fixed: 514041087 Change-Id: I1411c8ea360be0bd24cdfe451a5e43a87b3b7a8e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8234102 Reviewed-by: Nicolás Peña <[email protected]> Commit-Queue: Yi Gu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1678098} --- diff --git a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc index 6e0066d3..f287a104 100644 --- a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc +++ b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc @@ -196,7 +196,6 @@ return; } - input_protector_->VisibilityChanged(true); auto params = std::make_unique<tabs::TabDialogManager::Params>(); params->close_on_navigate = true; params->close_on_detach = true; @@ -657,6 +656,7 @@ rp_mode, &dialog_type_); } dialog_widget_ = CreateDialogWidget(); + dialog_widget_observation_.Observe(dialog_widget_.get()); dialog_widget_->MakeCloseSynchronous(base::BindOnce( &FedCmAccountSelectionView::OnUserClosedDialog, base::Unretained(this))); } @@ -1259,11 +1259,6 @@ UpdateDialogVisibility(false); scoped_ignore_input_events_.reset(); tab_accept_mouse_events_.reset(); - // TODO(crbug.com/331166928): This is only null in one test. Fix the test to - // match production. - if (input_protector_) { - input_protector_->VisibilityChanged(false); - } } base::WeakPtr<FedCmAccountSelectionView> @@ -1277,6 +1272,18 @@ } } +void FedCmAccountSelectionView::OnWidgetVisibilityChanged(views::Widget* widget, + bool visible) { + if (widget != dialog_widget_.get()) { + return; + } + // TODO(crbug.com/331166928): This is only null in one test. Fix the test to + // match production. + if (input_protector_) { + input_protector_->VisibilityChanged(visible); + } +} + void FedCmAccountSelectionView::ShowMultiAccountPicker( const std::vector<IdentityRequestAccountPtr>& accounts, const std::vector<IdentityProviderDataPtr>& idp_list, @@ -1388,6 +1395,7 @@ parked_dialog_view_.reset(); } scoped_ignore_input_events_.reset(); + dialog_widget_observation_.Reset(); dialog_widget_.reset(); widget_delegate_.reset(); @@ -1420,6 +1428,7 @@ if (should_show_dialog) { if (!dialog_widget_) { dialog_widget_ = CreateDialogWidget(); + dialog_widget_observation_.Observe(dialog_widget_.get()); dialog_widget_->MakeCloseSynchronous( base::BindOnce(&FedCmAccountSelectionView::OnUserClosedDialog, base::Unretained(this))); diff --git a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h index 8f1cdbf..feca0a4e 100644 --- a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h +++ b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h @@ -10,6 +10,7 @@ #include "base/gtest_prod_util.h" #include "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" +#include "base/scoped_observation.h" #include "chrome/browser/picture_in_picture/picture_in_picture_occlusion_observer.h" #include "chrome/browser/picture_in_picture/scoped_picture_in_picture_occlusion_observation.h" #include "chrome/browser/ui/page_action/page_action_observer.h" @@ -24,6 +25,7 @@ #include "content/public/browser/web_contents_observer.h" #include "ui/views/input_event_activation_protector.h" #include "ui/views/widget/widget_delegate.h" +#include "ui/views/widget/widget_observer.h" #include "ui/views/window/dialog_delegate.h" namespace tabs { @@ -69,7 +71,8 @@ public FedCmModalDialogView::Observer, public content::WebContentsObserver, public PictureInPictureOcclusionObserver, - public page_actions::PageActionObserver { + public page_actions::PageActionObserver, + public views::WidgetObserver { public: enum class DialogType { // FedCM dialog inherits a bubble dialog, which is typically shown on the @@ -161,6 +164,9 @@ void CloseModalDialog() override; void PrimaryMainFrameWasResized(bool width_changed) override; + // views::WidgetObserver: + void OnWidgetVisibilityChanged(views::Widget* widget, bool visible) override; + base::WeakPtr<FedCmAccountSelectionView> GetWeakPtr(); // Called when the associated tab enters the foreground. @@ -634,6 +640,9 @@ // Widget that owns the view. std::unique_ptr<views::Widget> dialog_widget_; + base::ScopedObservation<views::Widget, views::WidgetObserver> + dialog_widget_observation_{this}; + // This controls the contents of the dialog_widget_. Conceptually there // is a view if and only if there is a widget. The two are constructed // together and destroyed together. `dialog_widget_` owns diff --git a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc index e88157f..d7bea92 100644 --- a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc +++ b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc @@ -2695,4 +2695,94 @@ EXPECT_TRUE(controller->IsDialogWidgetVisible()); } +class FedCmAccountSelectionViewDesktopClickProtectionTest + : public FedCmAccountSelectionViewDesktopTest, + public testing::WithParamInterface<blink::mojom::RpMode> {}; + +INSTANTIATE_TEST_SUITE_P(All, + FedCmAccountSelectionViewDesktopClickProtectionTest, + testing::Values(blink::mojom::RpMode::kPassive, + blink::mojom::RpMode::kActive)); + +// Tests that switching to another tab and back re-arms the input event +// activation protector for both bubble (passive mode) and modal (active mode) +// dialogs, so that immediate clicks on the re-shown dialog are rejected while +// delayed clicks after the cooldown period are accepted. +TEST_P(FedCmAccountSelectionViewDesktopClickProtectionTest, + ClickProtectionAfterTabSwitch) { + std::unique_ptr<TestFedCmAccountSelectionView> controller = + CreateAndShow(accounts_, GetParam()); + + // Use a real InputEventActivationProtector. + controller->SetInputEventActivationProtectorForTesting( + std::make_unique<views::InputEventActivationProtector>()); + + // Advance time past the initial 500ms protection window. + task_environment()->FastForwardBy(base::Milliseconds(600)); + + // Simulate tab entering background and returning to foreground. + TabWillEnterBackground(controller.get()); + TabForegrounded(controller.get()); + + ui::MouseEvent immediate_event(ui::EventType::kMousePressed, gfx::Point(), + gfx::Point(), base::TimeTicks::Now(), + ui::EF_LEFT_MOUSE_BUTTON, 0); + + // Immediate click after re-show should be rejected by the protector. + EXPECT_FALSE(controller->OnAccountSelected(accounts_[0], immediate_event)); + + // Advance past the 500ms protection window. + task_environment()->FastForwardBy(base::Milliseconds(600)); + + ui::MouseEvent delayed_event(ui::EventType::kMousePressed, gfx::Point(), + gfx::Point(), base::TimeTicks::Now(), + ui::EF_LEFT_MOUSE_BUTTON, 0); + + // Click after delay should be accepted. + EXPECT_TRUE(controller->OnAccountSelected(accounts_[0], delayed_event)); +} + +// Tests that when a bubble dialog is hidden because it cannot fit in the web +// contents during a window resize, re-showing it upon resizing back re-arms the +// input event activation protector. (Note: Modal dialogs are tab-modal and +// centered, so they are not hidden when resized). +TEST_F(FedCmAccountSelectionViewDesktopTest, ClickProtectionAfterResize) { + std::unique_ptr<TestFedCmAccountSelectionView> controller = + CreateAndShow(accounts_); + + // Use a real InputEventActivationProtector. + controller->SetInputEventActivationProtectorForTesting( + std::make_unique<views::InputEventActivationProtector>()); + + // Advance time past the initial 500ms protection window. + task_environment()->FastForwardBy(base::Milliseconds(600)); + + // Resize so bubble dialog cannot fit, which hides it. + controller->can_fit_in_web_contents_ = false; + controller->PrimaryMainFrameWasResized(/*width_changed=*/true); + EXPECT_FALSE(controller->IsDialogWidgetVisible()); + + // Resize back so dialog can fit again, which re-shows it. + controller->can_fit_in_web_contents_ = true; + controller->PrimaryMainFrameWasResized(/*width_changed=*/true); + EXPECT_TRUE(controller->IsDialogWidgetVisible()); + + ui::MouseEvent immediate_event(ui::EventType::kMousePressed, gfx::Point(), + gfx::Point(), base::TimeTicks::Now(), + ui::EF_LEFT_MOUSE_BUTTON, 0); + + // Immediate click after re-show should be rejected. + EXPECT_FALSE(controller->OnAccountSelected(accounts_[0], immediate_event)); + + // Advance past the 500ms protection window. + task_environment()->FastForwardBy(base::Milliseconds(600)); + + ui::MouseEvent delayed_event(ui::EventType::kMousePressed, gfx::Point(), + gfx::Point(), base::TimeTicks::Now(), + ui::EF_LEFT_MOUSE_BUTTON, 0); + + // Click after delay should be accepted. + EXPECT_TRUE(controller->OnAccountSelected(accounts_[0], delayed_event)); +} + } // namespace webid
Regression Test / PoC
diff --git a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc
index e88157f..d7bea92 100644
--- a/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc
+++ b/chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop_unittest.cc
@@ -2695,4 +2695,94 @@
EXPECT_TRUE(controller->IsDialogWidgetVisible());
}
+class FedCmAccountSelectionViewDesktopClickProtectionTest
+ : public FedCmAccountSelectionViewDesktopTest,
+ public testing::WithParamInterface<blink::mojom::RpMode> {};
+
+INSTANTIATE_TEST_SUITE_P(All,
+ FedCmAccountSelectionViewDesktopClickProtectionTest,
+ testing::Values(blink::mojom::RpMode::kPassive,
+ blink::mojom::RpMode::kActive));
+
+// Tests that switching to another tab and back re-arms the input event
+// activation protector for both bubble (passive mode) and modal (active mode)
+// dialogs, so that immediate clicks on the re-shown dialog are rejected while
+// delayed clicks after the cooldown period are accepted.
+TEST_P(FedCmAccountSelectionViewDesktopClickProtectionTest,
+ ClickProtectionAfterTabSwitch) {
+ std::unique_ptr<TestFedCmAccountSelectionView> controller =
+ CreateAndShow(accounts_, GetParam());
+
+ // Use a real InputEventActivationProtector.
+ controller->SetInputEventActivationProtectorForTesting(
+ std::make_unique<views::InputEventActivationProtector>());
+
+ // Advance time past the initial 500ms protection window.
+ task_environment()->FastForwardBy(base::Milliseconds(600));
+
+ // Simulate tab entering background and returning to foreground.
+ TabWillEnterBackground(controller.get());
+ TabForegrounded(controller.get());
+
+ ui::MouseEvent immediate_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON, 0);
+
+ // Immediate click after re-show should be rejected by the protector.
+ EXPECT_FALSE(controller->OnAccountSelected(accounts_[0], immediate_event));
+
+ // Advance past the 500ms protection window.
+ task_environment()->FastForwardBy(base::Milliseconds(600));
+
+ ui::MouseEvent delayed_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON, 0);
+
+ // Click after delay should be accepted.
+ EXPECT_TRUE(controller->OnAccountSelected(accounts_[0], delayed_event));
+}
+
+// Tests that when a bubble dialog is hidden because it cannot fit in the web
+// contents during a window resize, re-showing it upon resizing back re-arms the
+// input event activation protector. (Note: Modal dialogs are tab-modal and
+// centered, so they are not hidden when resized).
+TEST_F(FedCmAccountSelectionViewDesktopTest, ClickProtectionAfterResize) {
+ std::unique_ptr<TestFedCmAccountSelectionView> controller =
+ CreateAndShow(accounts_);
+
+ // Use a real InputEventActivationProtector.
+ controller->SetInputEventActivationProtectorForTesting(
+ std::make_unique<views::InputEventActivationProtector>());
+
+ // Advance time past the initial 500ms protection window.
+ task_environment()->FastForwardBy(base::Milliseconds(600));
+
+ // Resize so bubble dialog cannot fit, which hides it.
+ controller->can_fit_in_web_contents_ = false;
+ controller->PrimaryMainFrameWasResized(/*width_changed=*/true);
+ EXPECT_FALSE(controller->IsDialogWidgetVisible());
+
+ // Resize back so dialog can fit again, which re-shows it.
+ controller->can_fit_in_web_contents_ = true;
+ controller->PrimaryMainFrameWasResized(/*width_changed=*/true);
+ EXPECT_TRUE(controller->IsDialogWidgetVisible());
+
+ ui::MouseEvent immediate_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON, 0);
+
+ // Immediate click after re-show should be rejected.
+ EXPECT_FALSE(controller->OnAccountSelected(accounts_[0], immediate_event));
+
+ // Advance past the 500ms protection window.
+ task_environment()->FastForwardBy(base::Milliseconds(600));
+
+ ui::MouseEvent delayed_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON, 0);
+
+ // Click after delay should be accepted.
+ EXPECT_TRUE(controller->OnAccountSelected(accounts_[0], delayed_event));
+}
+
} // namespace webid
Original Bug Report
Potential FedCM clickjacking protection bypass on re-show via TabDialogManager
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 account selection bubble fails to re-arm its clickjacking protection delay when re-shown after being hidden by the TabDialogManager. This allows an attacker to bypass the mandatory 500ms interaction delay by hiding and then immediately re-showing the bubble via a tab switch. An attacker could exploit this to trick a user into unintended account selection and identity disclosure.
Affected files:
chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.ccchrome/browser/ui/tabs/tab_dialog_manager.ccui/views/input_event_activation_protector.cc
Estimated timestamp from git blame: 2025-08-29
Technical Analysis
The FedCmAccountSelectionView on desktop fails to re-arm the InputEventActivationProtector when the account selection bubble is re-shown after being hidden by the TabDialogManager (e.g., during a tab switch). This creates a vulnerability where the 500ms protective delay against unintended interactions is not enforced upon the bubble’s reappearance.
In FedCmAccountSelectionView::UpdateDialogVisibilityAndPosition() (chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.cc), the logic for showing the dialog is as follows:
if (!dialog_widget_->IsVisible() && !IsDialogManaged(dialog_widget_.get())) {
ShowDialogWidget();
}
UpdateDialogVisibility(true);
The ShowDialogWidget() method is responsible for calling input_protector_->VisibilityChanged(true), which updates the protection timestamp to the current time, ensuring a 500ms delay before inputs are accepted.
However, when a tab containing a FedCM bubble is hidden (e.g., when a user opens a new tab) and then re-shown, IsDialogManaged() returns true. This causes UpdateDialogVisibilityAndPosition() to skip the call to ShowDialogWidget(). Instead, it calls UpdateDialogVisibility(true), which makes the widget visible via the TabDialogManager but does not notify the input_protector_ that visibility has changed.
Consequently, the InputEventActivationProtector continues to use the timestamp from the bubble’s original appearance. If more than 500ms have elapsed since that initial show, any interaction with the re-appeared bubble is accepted immediately upon its return to the foreground.
Secondary defenses, such as the WindowsStationarityMonitor, are also bypassed in this scenario. Because the browser window’s bounds remain unchanged and no WindowTreeHost is created or destroyed during a simple tab-switch cycle, the monitor does not detect a lack of stationarity and fails to trigger a timestamp update.
Potential Step-by-Step Reproduction
- Initial Request: A user navigates to an attacker-controlled site that triggers a FedCM account selection bubble.
- Wait: The attacker site waits for at least 500ms to ensure the initial protection interval has passed.
- Tab Switch: The attacker site opens a new foreground tab in the same window (requiring a user gesture). The
TabDialogManagerhides the FedCM bubble in the now-background tab. - Decoy Interaction: The new tab displays a decoy button at the same screen coordinates as an account selection row in the original FedCM bubble.
- Trigger Re-show: The attacker site closes the foreground tab (e.g., via
window.close()) just as the user is expected to click the decoy. - Clickjacking: The original tab gains focus and the FedCM bubble is re-shown. The user’s click lands on the re-shown bubble and is processed immediately, bypassing the intended security delay and disclosing the user’s identity to the attacker.
Suggested Fix
Ensure that input_protector_->VisibilityChanged(true) is called whenever the dialog’s visibility is set to true, regardless of whether it is managed by the TabDialogManager. A potential fix would be to move the VisibilityChanged(true) call into UpdateDialogVisibility(true) or ensure it is called explicitly in UpdateDialogVisibilityAndPosition whenever the dialog is about to be re-shown.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.