CVE-2026-79108
Overview
Background
- `HoverListView`
- the Views control that renders each selectable row (mechanism/passkey/security-key option) in the WebAuthn tab-modal dialog.
- `InputEventActivationProtector` / `IsPossiblyUnintendedInteraction`
- a Views mechanism (exposed via
DialogClientView) that drops clicks arriving within a short window (500 ms) after a dialog appears, defending against clickjacking/double-click hijacking. - `DialogClientView`
- the client area of a dialog that owns the input-protection state and the shown-time timestamp used to judge whether an interaction is likely unintended.
- Tapjacking/input protection interval
- the interval right after a dialog is presented during which a genuine user is unlikely to have deliberately clicked, so clicks are treated as possibly spoofed.
Root Cause Analysis
The WebAuthn dialog’s built-in accept/cancel buttons go through DialogClientView, which applies InputEventActivationProtector to reject clicks landing in the 500 ms post-presentation window. But the per-row buttons created by HoverListView bound their click callback directly to HoverListModel::OnListItemSelected via base::BindRepeating(..., base::Unretained(model_.get()), item_tag), bypassing the dialog’s protector entirely. As a result a row click occurring immediately upon dialog presentation was executed rather than dropped, so a page that could get a click delivered as the dialog appeared could cause selection of a WebAuthn mechanism without a deliberate user action.
The fix reroutes the row callback to a new HoverListView::OnListItemSelected(item_tag, event), which walks GetWidget()->widget_delegate()->AsDialogDelegate()->GetDialogClientView() and calls IsPossiblyUnintendedInteraction(event, /*allow_key_events=*/false), returning early (dropping the selection) when the interaction is within the protection window, and only otherwise forwarding to model_->OnListItemSelected(item_tag).
InputEventActivationProtector; the fix interposes HoverListView::OnListItemSelected, which consults IsPossiblyUnintendedInteraction before dispatching the selection.Attack Path
- Present the WebAuthn dialog
A page initiates a Web Authentication request, causing the tab-modal authenticator dialog with its
HoverListViewrows to appear. - Deliver a click into the protection window Using timing or overlay/clickjacking techniques (inferred), the attacker arranges for a click to land on the top row button within the 500 ms input-protection interval as the dialog is shown.
- Bypass the protector
Because the row callback was bound directly to
HoverListModel::OnListItemSelected, the click was not routed throughDialogClientView’s protector and was executed (the regression test shows the pre-fix callback firing immediately). - Unintended mechanism selection The victim’s authenticator mechanism (e.g. a transport/security key) is selected without a deliberate user choice, a UI-misrepresentation/interaction-hijack against the WebAuthn flow.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Eventchrome/browser/ui/views/webauthn/hover_list_view.h |
modified | |
Separatorchrome/browser/ui/views/webauthn/hover_list_view.h |
modified | |
ifchrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc |
modified | |
StepTransitionObserverchrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc |
modified | |
BindLambdaForTestingchrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/views/webauthn/hover_list_view.hchrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.ccchrome/browser/ui/views/webauthn/BUILD.gnchrome/browser/ui/views/webauthn/hover_list_view.cc
Audit Directions
- Custom row/list buttons in dialogsAudit dialog subviews that create their own buttons and bind callbacks directly to models/handlers; verify they still pass through
InputEventActivationProtector/IsPossiblyUnintendedInteraction. - Security-sensitive dialogsReview other permission/authentication/consent dialogs (WebAuthn, payments, permissions) for interactive elements that bypass the dialog’s default input protection.
- Callback binding shortcutsFlag
base::BindRepeating(..., base::Unretained(model))on UI controls where the click should first be screened by the owning view/dialog for unintended-interaction protection.
Patch
From 9d7d8afd1cb8ef089b616691c7a579996ef1bd2b Mon Sep 17 00:00:00 2001 From: Adem Derinel <[email protected]> Date: Mon, 06 Jul 2026 08:47:38 -0700 Subject: [PATCH] [WebAuthn] Prevent input protection bypass on HoverListView row clicks The WebAuthn tab-modal dialog HoverListView row buttons were not protected by DialogClientView InputEventActivationProtector. As a result, row clicks occurring immediately upon dialog presentation during the double-click/input protection interval (500 ms) were executed instead of dropped. This change intercepts row button selections across HoverListView (OnListItemSelected), querying DialogClientView::IsPossiblyUnintendedInteraction(event, false) before dispatching item selection callbacks (HoverListModel::OnListItemSelected) or accepting actions. Added regression browser test (AuthenticatorDialogViewTest.InputEventProtection) to verify row clicks inside the protection window are dropped. TAG=agy Fixed: 522304549 Change-Id: Ia4d54fce53a04da71b0095b988c4588f046c9c7a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8039609 Reviewed-by: Nina Satragno <[email protected]> Commit-Queue: Adem Derinel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1657224} --- diff --git a/chrome/browser/ui/views/webauthn/hover_list_view.cc b/chrome/browser/ui/views/webauthn/hover_list_view.cc index 3f4e07a..f9e224b 100644 --- a/chrome/browser/ui/views/webauthn/hover_list_view.cc +++ b/chrome/browser/ui/views/webauthn/hover_list_view.cc @@ -19,6 +19,7 @@ #include "ui/base/models/image_model.h" #include "ui/base/ui_base_features.h" #include "ui/color/color_id.h" +#include "ui/events/event.h" #include "ui/gfx/geometry/insets.h" #include "ui/views/border.h" #include "ui/views/controls/button/button.h" @@ -26,6 +27,10 @@ #include "ui/views/controls/separator.h" #include "ui/views/layout/box_layout.h" #include "ui/views/layout/fill_layout.h" +#include "ui/views/widget/widget.h" +#include "ui/views/widget/widget_delegate.h" +#include "ui/views/window/dialog_client_view.h" +#include "ui/views/window/dialog_delegate.h" namespace { @@ -89,10 +94,12 @@ std::u16string description_text, bool enabled, int item_tag) { + // Safe because the hover button is added as a child view owned by `this`, + // guaranteeing `this` strictly outlives `hover_button` and its callback. auto hover_button = CreateHoverButtonForListItem( icon, item_text, description_text, enabled, - base::BindRepeating(&HoverListModel::OnListItemSelected, - base::Unretained(model_.get()), item_tag)); + base::BindRepeating(&HoverListView::OnListItemSelected, + base::Unretained(this), item_tag)); auto* list_item_view_ptr = hover_button.release(); item_container_->AddChildViewRaw(list_item_view_ptr); @@ -102,6 +109,22 @@ item_tag, ListItemViews{list_item_view_ptr, separator}); } +void HoverListView::OnListItemSelected(int item_tag, const ui::Event& event) { + if (GetWidget() && GetWidget()->widget_delegate()) { + if (views::DialogDelegate* dialog_delegate = + GetWidget()->widget_delegate()->AsDialogDelegate()) { + if (views::DialogClientView* dialog_client_view = + dialog_delegate->GetDialogClientView()) { + if (dialog_client_view->IsPossiblyUnintendedInteraction( + event, /*allow_key_events=*/false)) { + return; + } + } + } + } + model_->OnListItemSelected(item_tag); +} + views::Button& HoverListView::GetTopListItemView() const { DCHECK(!tags_to_list_item_views_.empty()); return *tags_to_list_item_views_.begin()->second.item_view; diff --git a/chrome/browser/ui/views/webauthn/hover_list_view.h b/chrome/browser/ui/views/webauthn/hover_list_view.h index b51535ad..8fa378f 100644 --- a/chrome/browser/ui/views/webauthn/hover_list_view.h +++ b/chrome/browser/ui/views/webauthn/hover_list_view.h @@ -17,6 +17,10 @@ #include "ui/views/controls/scroll_view.h" #include "ui/views/view.h" +namespace ui { +class Event; +} // namespace ui + namespace views { class Separator; } // namespace views @@ -57,6 +61,7 @@ std::u16string item_description, bool enabled, int item_tag); + void OnListItemSelected(int item_tag, const ui::Event& event); views::Button& GetTopListItemView() const; int GetPreferredViewHeight() const; diff --git a/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc b/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc index b8e0252..092bb168 100644 --- a/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc +++ b/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc @@ -8,7 +8,9 @@ #include "base/functional/callback_helpers.h" #include "base/memory/scoped_refptr.h" +#include "base/test/bind.h" #include "build/build_config.h" +#include "chrome/app/vector_icons/vector_icons.h" #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/browser_window.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" @@ -17,11 +19,14 @@ #include "chrome/browser/ui/views/webauthn/authenticator_request_dialog_view_controller_views.h" #include "chrome/browser/ui/views/webauthn/authenticator_request_dialog_view_test_api.h" #include "chrome/browser/ui/views/webauthn/authenticator_request_sheet_view.h" +#include "chrome/browser/ui/views/webauthn/hover_list_view.h" #include "chrome/browser/ui/webauthn/authenticator_request_sheet_model.h" #include "chrome/browser/webauthn/authenticator_request_dialog_model.h" #include "content/public/test/browser_test.h" #include "testing/gtest/include/gtest/gtest.h" #include "ui/views/controls/label.h" +#include "ui/views/test/button_test_api.h" +#include "ui/views/window/dialog_client_view.h" namespace { @@ -92,6 +97,36 @@ } }; +HoverListView* FindHoverListView(views::View* root) { + if (!root) { + return nullptr; + } + if (auto* hover_list = views::AsViewClass<HoverListView>(root)) { + return hover_list; + } + for (views::View* child : root->children()) { + if (HoverListView* found = FindHoverListView(child)) { + return found; + } + } + return nullptr; +} + +views::Button* FindTopListItemButton(views::View* root) { + if (!root) { + return nullptr; + } + if (auto* button = views::AsViewClass<views::Button>(root)) { + return button; + } + for (views::View* child : root->children()) { + if (views::Button* found = FindTopListItemButton(child)) { + return found; + } + } + return nullptr; +} + } // namespace class StepTransitionObserver @@ -163,3 +198,61 @@ IN_PROC_BROWSER_TEST_F(AuthenticatorDialogViewTest, InvokeUi_ReplaceSheet) { ShowAndVerifyUi(); } + +IN_PROC_BROWSER_TEST_F(AuthenticatorDialogViewTest, + HoverListViewInputEventProtection) { + content::WebContents* const web_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + CHECK(web_contents); + + int mechanism_callback_count_ = 0; + auto dialog_model = + base::MakeRefCounted<AuthenticatorRequestDialogModel>(nullptr); + dialog_model->relying_party_id = "example.com"; + dialog_model->mechanisms.emplace_back( + AuthenticatorRequestDialogModel::Mechanism::Transport( + AuthenticatorTransport::kUsbHumanInterfaceDevice), + u"Security key", kPasskeyUsbDarkCustomIcon, + base::BindRepeating( + base::BindLambdaForTesting([&]() { mechanism_callback_count_++; }))); + dialog_model->SetStep( + AuthenticatorRequestDialogModel::Step::kMechanismSelection); + + auto view_controller = + std::make_unique<AuthenticatorRequestDialogViewControllerViews>( + web_contents, dialog_model.get()); + + // Trigger OnStepTransition() so that view_controller creates the sheet for + // the current step and calls Show(). + view_controller->OnStepTransition(); + + AuthenticatorRequestSheetView* sheet = + test::AuthenticatorRequestDialogViewTestApi::GetSheet( + view_controller.get()); + ASSERT_TRUE(sheet); + + HoverListView* hover_list_view = FindHoverListView(sheet); + ASSERT_TRUE(hover_list_view); + + views::Button* top_row_button = FindTopListItemButton(hover_list_view); + ASSERT_TRUE(top_row_button); + + ui::MouseEvent click_event(ui::EventType::kMousePressed, gfx::Point(), + gfx::Point(), base::TimeTicks::Now(), + ui::EF_LEFT_MOUSE_BUTTON, + ui::EF_LEFT_MOUSE_BUTTON); + + views::test::ButtonTestApi(top_row_button).NotifyClick(click_event); + EXPECT_EQ(mechanism_callback_count_, 0); + + ASSERT_TRUE(sheet->GetWidget()); + auto* dialog_delegate = + sheet->GetWidget()->widget_delegate()->AsDialogDelegate(); + ASSERT_TRUE(dialog_delegate); + auto* dialog_client_view = dialog_delegate->GetDialogClientView(); + ASSERT_TRUE(dialog_client_view); + + dialog_client_view->ResetViewShownTimeStampForTesting(); + views::test::ButtonTestApi(top_row_button).NotifyClick(click_event); + EXPECT_EQ(mechanism_callback_count_, 1); +} diff --git a/chrome/browser/ui/views/webauthn/BUILD.gn b/chrome/browser/ui/views/webauthn/BUILD.gn index fcb6201..08b0655 100644 --- a/chrome/browser/ui/views/webauthn/BUILD.gn +++ b/chrome/browser/ui/views/webauthn/BUILD.gn @@ -201,5 +201,6 @@ "//content/test:test_support", "//testing/gtest", "//ui/views", + "//ui/views:test_support", ] }
Regression Test / PoC
diff --git a/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc b/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc
index b8e0252..092bb168 100644
--- a/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc
+++ b/chrome/browser/ui/views/webauthn/authenticator_dialog_view_browsertest.cc
@@ -8,7 +8,9 @@
#include "base/functional/callback_helpers.h"
#include "base/memory/scoped_refptr.h"
+#include "base/test/bind.h"
#include "build/build_config.h"
+#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
@@ -17,11 +19,14 @@
#include "chrome/browser/ui/views/webauthn/authenticator_request_dialog_view_controller_views.h"
#include "chrome/browser/ui/views/webauthn/authenticator_request_dialog_view_test_api.h"
#include "chrome/browser/ui/views/webauthn/authenticator_request_sheet_view.h"
+#include "chrome/browser/ui/views/webauthn/hover_list_view.h"
#include "chrome/browser/ui/webauthn/authenticator_request_sheet_model.h"
#include "chrome/browser/webauthn/authenticator_request_dialog_model.h"
#include "content/public/test/browser_test.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/views/controls/label.h"
+#include "ui/views/test/button_test_api.h"
+#include "ui/views/window/dialog_client_view.h"
namespace {
@@ -92,6 +97,36 @@
}
};
+HoverListView* FindHoverListView(views::View* root) {
+ if (!root) {
+ return nullptr;
+ }
+ if (auto* hover_list = views::AsViewClass<HoverListView>(root)) {
+ return hover_list;
+ }
+ for (views::View* child : root->children()) {
+ if (HoverListView* found = FindHoverListView(child)) {
+ return found;
+ }
+ }
+ return nullptr;
+}
+
+views::Button* FindTopListItemButton(views::View* root) {
+ if (!root) {
+ return nullptr;
+ }
+ if (auto* button = views::AsViewClass<views::Button>(root)) {
+ return button;
+ }
+ for (views::View* child : root->children()) {
+ if (views::Button* found = FindTopListItemButton(child)) {
+ return found;
+ }
+ }
+ return nullptr;
+}
+
} // namespace
class StepTransitionObserver
@@ -163,3 +198,61 @@
IN_PROC_BROWSER_TEST_F(AuthenticatorDialogViewTest, InvokeUi_ReplaceSheet) {
ShowAndVerifyUi();
}
+
+IN_PROC_BROWSER_TEST_F(AuthenticatorDialogViewTest,
+ HoverListViewInputEventProtection) {
+ content::WebContents* const web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ CHECK(web_contents);
+
+ int mechanism_callback_count_ = 0;
+ auto dialog_model =
+ base::MakeRefCounted<AuthenticatorRequestDialogModel>(nullptr);
+ dialog_model->relying_party_id = "example.com";
+ dialog_model->mechanisms.emplace_back(
+ AuthenticatorRequestDialogModel::Mechanism::Transport(
+ AuthenticatorTransport::kUsbHumanInterfaceDevice),
+ u"Security key", kPasskeyUsbDarkCustomIcon,
+ base::BindRepeating(
+ base::BindLambdaForTesting([&]() { mechanism_callback_count_++; })));
+ dialog_model->SetStep(
+ AuthenticatorRequestDialogModel::Step::kMechanismSelection);
+
+ auto view_controller =
+ std::make_unique<AuthenticatorRequestDialogViewControllerViews>(
+ web_contents, dialog_model.get());
+
+ // Trigger OnStepTransition() so that view_controller creates the sheet for
+ // the current step and calls Show().
+ view_controller->OnStepTransition();
+
+ AuthenticatorRequestSheetView* sheet =
+ test::AuthenticatorRequestDialogViewTestApi::GetSheet(
+ view_controller.get());
+ ASSERT_TRUE(sheet);
+
+ HoverListView* hover_list_view = FindHoverListView(sheet);
+ ASSERT_TRUE(hover_list_view);
+
+ views::Button* top_row_button = FindTopListItemButton(hover_list_view);
+ ASSERT_TRUE(top_row_button);
+
+ ui::MouseEvent click_event(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON,
+ ui::EF_LEFT_MOUSE_BUTTON);
+
+ views::test::ButtonTestApi(top_row_button).NotifyClick(click_event);
+ EXPECT_EQ(mechanism_callback_count_, 0);
+
+ ASSERT_TRUE(sheet->GetWidget());
+ auto* dialog_delegate =
+ sheet->GetWidget()->widget_delegate()->AsDialogDelegate();
+ ASSERT_TRUE(dialog_delegate);
+ auto* dialog_client_view = dialog_delegate->GetDialogClientView();
+ ASSERT_TRUE(dialog_client_view);
+
+ dialog_client_view->ResetViewShownTimeStampForTesting();
+ views::test::ButtonTestApi(top_row_button).NotifyClick(click_event);
+ EXPECT_EQ(mechanism_callback_count_, 1);
+}