CVE-2026-87473
Overview
Background
- Web app launch consent dialogs
- the Views dialogs shown before a PWA handles a file or protocol, implemented by
LaunchAppUserChoiceDialogView(base class) and its subclassesFileHandlerLaunchDialogViewandProtocolHandlerLaunchDialogView. - Keyjacking
- an attack where a dialog is dismissed/accepted by key presses the user did not intend for it, e.g. queued
Enterpresses landing on a dialog the moment it appears. - `SetDefaultButton()`
- sets which button (
ui::mojom::DialogButton::kOkorkCancel) is activated when the user pressesEnter. - `ShouldAllowKeyEventsDuringInputProtection()`
- a
views::DialogDelegateoverride; returningfalseenforces the input-activation delay (about 500ms) during which key events are ignored so a freshly shown dialog cannot be instantly actuated.
Root Cause Analysis
The web app launch consent dialogs could be accepted immediately by keyboard input at the moment they appeared, because two protections were absent in the shared base class. First, the default button was effectively OK for the File Handler dialog, so a stray or attacker-timed Enter key press accepted the launch rather than canceling it. Second, LaunchAppUserChoiceDialogView did not override ShouldAllowKeyEventsDuringInputProtection(), so key events were honored during the brief input-activation delay that is meant to filter out input the user did not deliberately aim at the just-shown dialog. This violated the invariant that a security-consequential consent dialog should not be actionable by input arriving before the user has had time to perceive it, and should default to the safe (deny) choice.
The fix adds SetDefaultButton(kCancel) in the base class constructor so Enter defaults to canceling, and overrides ShouldAllowKeyEventsDuringInputProtection() to return false so key events are suppressed during the activation delay; the now-redundant SetDefaultButton(kCancel) in ProtocolHandlerLaunchDialogView is removed since the base class handles it.
OK) on Enter and processed key events during the input-protection window; the fix makes Cancel the default button and returns false from ShouldAllowKeyEventsDuringInputProtection() so rapid/premature key presses cannot silently confirm the launch.Attack Path
- Trigger a launch dialog A malicious page or file causes Chrome to show a PWA file-handler or protocol-handler launch consent dialog (e.g. via a registered protocol or file handler).
- Queue key input
The attacker arranges for
Enterkey presses to arrive as the dialog appears, for instance by getting the user to hold or repeatedly press a key while the page navigates or invokes the handler (inferred timing technique). - Bypass the delay
Pre-patch, key events were allowed during the input-activation delay, so an
Enterpress landing right after the dialog opened was processed instead of being ignored. - Activate the default (accept)
Because the default button was
OK(for the File Handler dialog), the processedEnteractivated acceptance, launching the app / handling the file or protocol without deliberate user consent. - Consequence
The web app launches and handles the attacker-influenced file or protocol; any further impact beyond the unintended launch is not shown by the diff
(inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fchrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_view.ccchrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.ccchrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.ccchrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.hchrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
Audit Directions
- Default-accept consent dialogsAudit security-consequential dialogs for a default button set to
OK/accept; consent/permission dialogs should default toCancel/deny so a strayEnteris safe. - Missing input-protection overridesLook for
views::DialogDelegatesubclasses that gate an action but do not overrideShouldAllowKeyEventsDuringInputProtection()to returnfalse, leaving them keyjackable during the activation delay. - Base-vs-subclass protection gapsWhen a protection is added per-subclass (as
SetDefaultButton(kCancel)was only in the protocol dialog), verify every sibling dialog inherits it; hoisting to the shared base class prevents one subclass (here the File Handler dialog) from silently lacking it.
Patch
From a245abe0732f3d9f180163b2de0ab52f54ec59c1 Mon Sep 17 00:00:00 2001
From: Kunjan Vinay Patel <ksukh@google.com>
Date: Mon, 10 Aug 2026 14:31:48 -0700
Subject: [PATCH] [PWA] Prevent keyjacking on web app launch consent dialogs.
This change enhances security for web app launch dialogs (covering both
File and Protocol handlers) to prevent accidental or malicious bypasses
via rapid key presses:
- Sets the default focused button to 'Cancel' in the base class
LaunchAppUserChoiceDialogView.
- Enforces the 500ms input activation delay for keyboard events by
overriding ShouldAllowKeyEventsDuringInputProtection() to return
false.
Previously, these dialogs could be immediately accepted via the 'Enter'
key because the default button was 'OK' (specifically for File Handlers)
and key events were not blocked during the initial activation delay.
Bug: 537101736
Change-Id: Ie7396ee149ca498e0df19441c548c8eaa22e9d42
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8226085
Reviewed-by: Dibyajyoti Pal <dibyapal@chromium.org>
Commit-Queue: Kunjan Patel <ksukh@chromium.org>
Reviewed-by: Luke Deng <lukedeng@google.com>
Cr-Commit-Position: refs/heads/main@{#1676726}
---
diff --git a/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.cc b/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.cc
index 4141892..003b136 100644
--- a/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.cc
+++ b/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.cc
@@ -83,6 +83,8 @@
SetCanResize(false);
set_draggable(true);
+ SetDefaultButton(static_cast<int>(ui::mojom::DialogButton::kCancel));
+
SetAcceptCallback(base::BindOnce(&LaunchAppUserChoiceDialogView::OnAccepted,
base::Unretained(this)));
@@ -233,6 +235,11 @@
gfx::ImageSkia::CreateFrom1xBitmap(std::move(masked_bitmap))));
}
+bool LaunchAppUserChoiceDialogView::ShouldAllowKeyEventsDuringInputProtection()
+ const {
+ return false;
+}
+
BEGIN_METADATA(LaunchAppUserChoiceDialogView)
END_METADATA
diff --git a/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.h b/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.h
index 406708d3..7c164a5b 100644
--- a/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.h
+++ b/chrome/browser/ui/views/web_apps/launch_app_user_choice_dialog_view.h
@@ -46,6 +46,9 @@
void Init();
+ // views::DialogDelegate:
+ bool ShouldAllowKeyEventsDuringInputProtection() const override;
+
static void SetDefaultRememberSelectionForTesting(bool remember_selection);
protected:
diff --git a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_view.cc b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_view.cc
index 94f1812..32b758e 100644
--- a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_view.cc
+++ b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_view.cc
@@ -39,7 +39,6 @@
SetButtonLabel(
ui::mojom::DialogButton::kCancel,
l10n_util::GetStringUTF16(IDS_WEB_APP_PERMISSION_NEGATIVE_BUTTON));
- SetDefaultButton(static_cast<int>(ui::mojom::DialogButton::kCancel));
}
ProtocolHandlerLaunchDialogView::~ProtocolHandlerLaunchDialogView() = default;
diff --git a/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc b/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
index 6aa7bdb2..0af938e 100644
--- a/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
+++ b/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
@@ -226,6 +226,24 @@
EXPECT_EQ(1U, GlobalBrowserCollection::GetInstance()->GetSize());
}
+IN_PROC_BROWSER_TEST_F(FileHandlerLaunchDialogTest,
+ DefaultButtonAndInputProtection) {
+ views::NamedWidgetShownWaiter waiter(views::test::AnyWidgetTestPasskey{},
+ "FileHandlerLaunchDialogView");
+ LaunchAppWithFiles({{base::FilePath::FromASCII("foo.txt")}});
+ views::Widget* widget = waiter.WaitIfNeededAndGet();
+ ASSERT_NE(widget, nullptr);
+ views::DialogDelegate* dialog_delegate =
+ widget->widget_delegate()->AsDialogDelegate();
+ ASSERT_NE(dialog_delegate, nullptr);
+
+ EXPECT_EQ(dialog_delegate->GetDefaultDialogButton(),
+ static_cast<int>(ui::mojom::DialogButton::kCancel));
+ EXPECT_FALSE(dialog_delegate->ShouldAllowKeyEventsDuringInputProtection());
+
+ views::test::CancelDialog(widget);
+}
+
IN_PROC_BROWSER_TEST_F(FileHandlerLaunchDialogTest, DisallowAndRemember) {
// One normal browser window exists.
EXPECT_EQ(1U, GlobalBrowserCollection::GetInstance()->GetSize());
diff --git a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
index 868700c..00c5c32 100644
--- a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
+++ b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
@@ -108,6 +108,29 @@
}
IN_PROC_BROWSER_TEST_F(ProtocolHandlerLaunchDialogBrowserTest,
+ DefaultButtonAndInputProtection) {
+ views::NamedWidgetShownWaiter waiter(views::test::AnyWidgetTestPasskey{},
+ "ProtocolHandlerLaunchDialogView");
+ GURL protocol_url("web+test://test");
+ webapps::AppId test_app_id = InstallTestWebApp(browser()->GetProfile());
+
+ ShowWebAppProtocolLaunchDialog(protocol_url, browser()->GetProfile(),
+ test_app_id, base::DoNothing());
+
+ views::Widget* widget = waiter.WaitIfNeededAndGet();
+ ASSERT_NE(widget, nullptr);
+ views::DialogDelegate* dialog_delegate =
+ widget->widget_delegate()->AsDialogDelegate();
+ ASSERT_NE(dialog_delegate, nullptr);
+
+ EXPECT_EQ(dialog_delegate->GetDefaultDialogButton(),
+ static_cast<int>(ui::mojom::DialogButton::kCancel));
+ EXPECT_FALSE(dialog_delegate->ShouldAllowKeyEventsDuringInputProtection());
+
+ views::test::CancelDialog(widget);
+}
+
+IN_PROC_BROWSER_TEST_F(ProtocolHandlerLaunchDialogBrowserTest,
ProtocolHandlerIntentPickerDialog_DisallowAndRemember) {
ProtocolHandlerLaunchDialogView::SetDefaultRememberSelectionForTesting(true);
ShowDialogAndCloseWithReason(
Regression Test / PoC
diff --git a/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc b/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
index 6aa7bdb2..0af938e 100644
--- a/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
+++ b/chrome/browser/ui/views/web_apps/file_handler_launch_dialog_browsertest.cc
@@ -226,6 +226,24 @@
EXPECT_EQ(1U, GlobalBrowserCollection::GetInstance()->GetSize());
}
+IN_PROC_BROWSER_TEST_F(FileHandlerLaunchDialogTest,
+ DefaultButtonAndInputProtection) {
+ views::NamedWidgetShownWaiter waiter(views::test::AnyWidgetTestPasskey{},
+ "FileHandlerLaunchDialogView");
+ LaunchAppWithFiles({{base::FilePath::FromASCII("foo.txt")}});
+ views::Widget* widget = waiter.WaitIfNeededAndGet();
+ ASSERT_NE(widget, nullptr);
+ views::DialogDelegate* dialog_delegate =
+ widget->widget_delegate()->AsDialogDelegate();
+ ASSERT_NE(dialog_delegate, nullptr);
+
+ EXPECT_EQ(dialog_delegate->GetDefaultDialogButton(),
+ static_cast<int>(ui::mojom::DialogButton::kCancel));
+ EXPECT_FALSE(dialog_delegate->ShouldAllowKeyEventsDuringInputProtection());
+
+ views::test::CancelDialog(widget);
+}
+
IN_PROC_BROWSER_TEST_F(FileHandlerLaunchDialogTest, DisallowAndRemember) {
// One normal browser window exists.
EXPECT_EQ(1U, GlobalBrowserCollection::GetInstance()->GetSize());
diff --git a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
index 868700c..00c5c32 100644
--- a/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
+++ b/chrome/browser/ui/views/web_apps/protocol_handler_launch_dialog_browsertest.cc
@@ -108,6 +108,29 @@
}
IN_PROC_BROWSER_TEST_F(ProtocolHandlerLaunchDialogBrowserTest,
+ DefaultButtonAndInputProtection) {
+ views::NamedWidgetShownWaiter waiter(views::test::AnyWidgetTestPasskey{},
+ "ProtocolHandlerLaunchDialogView");
+ GURL protocol_url("web+test://test");
+ webapps::AppId test_app_id = InstallTestWebApp(browser()->GetProfile());
+
+ ShowWebAppProtocolLaunchDialog(protocol_url, browser()->GetProfile(),
+ test_app_id, base::DoNothing());
+
+ views::Widget* widget = waiter.WaitIfNeededAndGet();
+ ASSERT_NE(widget, nullptr);
+ views::DialogDelegate* dialog_delegate =
+ widget->widget_delegate()->AsDialogDelegate();
+ ASSERT_NE(dialog_delegate, nullptr);
+
+ EXPECT_EQ(dialog_delegate->GetDefaultDialogButton(),
+ static_cast<int>(ui::mojom::DialogButton::kCancel));
+ EXPECT_FALSE(dialog_delegate->ShouldAllowKeyEventsDuringInputProtection());
+
+ views::test::CancelDialog(widget);
+}
+
+IN_PROC_BROWSER_TEST_F(ProtocolHandlerLaunchDialogBrowserTest,
ProtocolHandlerIntentPickerDialog_DisallowAndRemember) {
ProtocolHandlerLaunchDialogView::SetDefaultRememberSelectionForTesting(true);
ShowDialogAndCloseWithReason(