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 <derinel@google.com>
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 <nsatragno@chromium.org>
Commit-Queue: Adem Derinel <derinel@google.com>
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);
+}