Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect security UI in Views
DescriptionIncorrect security UI in Views
ComponentViews
Bug ClassLogic Error
Tracker514079793
Fix commite64e0f3802d5 (chromium/src) +42/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
modified
TEST_F
ui/views/bubble/bubble_dialog_model_host_unittest.cc
modified

Files Changed

  • chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
  • ui/base/models/dialog_model.h
  • ui/views/bubble/bubble_dialog_model_host.cc
  • ui/views/bubble/bubble_dialog_model_host.h
  • ui/views/bubble/bubble_dialog_model_host_unittest.cc
From e64e0f3802d5c7166a374f70ed21adae5f9d33df Mon Sep 17 00:00:00 2001
From: Mohamed Amir Yosef <[email protected]>
Date: Tue, 19 May 2026 12:40:46 -0700
Subject: [PATCH] [Views] Allow dialog models to protect key events from keyjacking

Currently, key events bypass the dialog input event activation protector
(which imposes a 500ms safety delay to prevent accidental confirmation).
This bypass poses a keyjacking security risk for highly sensitive safety
interstitial dialogs, such as the Digital Identity safety interstitial
on desktop, where a malicious page could trick a user into accidentally
approving the dialog via rapid keystrokes.

This CL adds SetShouldAllowKeyEventsDuringInputProtection(bool) to the
ui::DialogModel::Builder. BubbleDialogModelHost overrides
ShouldAllowKeyEventsDuringInputProtection() to honor this configuration,
and the Digital Identity safety interstitial is updated to enable it.

Fixed: b:514079793
Test: views_unittests --gtest_filter=BubbleDialogModelHostTest.ShouldAllowKeyEventsDuringInputProtection
Change-Id: I4e8ca5ab99cbee4a24f6fa5e17750dd5ad05ddca
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7852411
Reviewed-by: Dana Fried <[email protected]>
Auto-Submit: Mohamed Amir Yosef <[email protected]>
Reviewed-by: Thomas Lukaszewicz <[email protected]>
Commit-Queue: Mohamed Amir Yosef <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1633062}
---

diff --git a/chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc b/chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
index c851f517..05719fe 100644
--- a/chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
+++ b/chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
@@ -159,7 +159,8 @@
       .SetTitle(l10n_util::GetStringUTF16(
           IDS_WEB_DIGITAL_CREDENTIALS_INTERSTITIAL_DIALOG_TITLE))
       .AddParagraph(ui::DialogModelLabel(body_text))
-      .SetInitiallyFocusedField(kContinueButtonId);
+      .SetInitiallyFocusedField(kContinueButtonId)
+      .SetEnableInputProtection(true);
 
   if (was_request_aborted) {
     dialog_model_builder.AddParagraph(
diff --git a/ui/base/models/dialog_model.h b/ui/base/models/dialog_model.h
index 325e7eb0..705a7d6 100644
--- a/ui/base/models/dialog_model.h
+++ b/ui/base/models/dialog_model.h
@@ -262,6 +262,11 @@
       return *this;
     }
 
+    Builder& SetEnableInputProtection(bool enable) {
+      model_->enable_input_protection_ = enable;
+      return *this;
+    }
+
     // Disables the default behavior that the dialog closes when Escape is
     // pressed.
     // Only certain dialogs are allowed to change this properly, as it has
@@ -652,6 +657,10 @@
     return close_on_escape_;
   }
 
+  bool enable_input_protection(base::PassKey<DialogModelHost>) const {
+    return enable_input_protection_;
+  }
+
   DialogModelSection* contents() { return &contents_; }
 
   DialogModelDelegate* delegate() { return delegate_.get(); }
@@ -675,6 +684,7 @@
   std::optional<bool> override_show_close_button_;
   bool close_on_deactivate_ = true;
   bool close_on_escape_ = true;
+  bool enable_input_protection_ = false;
   std::string internal_name_;
   std::u16string title_;
   std::u16string accessible_title_;
diff --git a/ui/views/bubble/bubble_dialog_model_host.cc b/ui/views/bubble/bubble_dialog_model_host.cc
index 7e6417b..1db7d43 100644
--- a/ui/views/bubble/bubble_dialog_model_host.cc
+++ b/ui/views/bubble/bubble_dialog_model_host.cc
@@ -1016,6 +1016,12 @@
   return BubbleDialogDelegate::OnCloseRequested(close_reason);
 }
 
+bool BubbleDialogModelHost::ShouldAllowKeyEventsDuringInputProtection() const {
+  return model_
+             ? !model_->enable_input_protection(DialogModelHost::GetPassKey())
+             : true;
+}
+
 BubbleDialogModelHost::~BubbleDialogModelHost() {
   // Detach ContentsView as it's referring to state that's about to be
   // destroyed.
diff --git a/ui/views/bubble/bubble_dialog_model_host.h b/ui/views/bubble/bubble_dialog_model_host.h
index ca30279..4d234002 100644
--- a/ui/views/bubble/bubble_dialog_model_host.h
+++ b/ui/views/bubble/bubble_dialog_model_host.h
@@ -117,6 +117,7 @@
   View* GetInitiallyFocusedView() override;
   void OnWidgetInitialized() override;
   bool OnCloseRequested(views::Widget::ClosedReason close_reason) override;
+  bool ShouldAllowKeyEventsDuringInputProtection() const override;
 
   // ui::DialogModelHost:
   void Close() override;
diff --git a/ui/views/bubble/bubble_dialog_model_host_unittest.cc b/ui/views/bubble/bubble_dialog_model_host_unittest.cc
index 21c3c4d..aad0f37 100644
--- a/ui/views/bubble/bubble_dialog_model_host_unittest.cc
+++ b/ui/views/bubble/bubble_dialog_model_host_unittest.cc
@@ -480,4 +480,27 @@
   EXPECT_TRUE(bubble_widget->IsClosed());
 }
 
+TEST_F(BubbleDialogModelHostTest, ShouldAllowKeyEventsDuringInputProtection) {
+  std::unique_ptr<Widget> anchor_widget = CreateTestWidget(
+      Widget::InitParams::CLIENT_OWNS_WIDGET, Widget::InitParams::TYPE_WINDOW);
+  anchor_widget->Show();
+
+  std::unique_ptr<ui::DialogModel> dialog_model_default =
+      ui::DialogModel::Builder().AddOkButton(base::DoNothing()).Build();
+  auto host_default = std::make_unique<BubbleDialogModelHost>(
+      std::move(dialog_model_default), anchor_widget->GetContentsView(),
+      BubbleBorder::Arrow::TOP_RIGHT);
+  EXPECT_TRUE(host_default->ShouldAllowKeyEventsDuringInputProtection());
+
+  std::unique_ptr<ui::DialogModel> dialog_model_false =
+      ui::DialogModel::Builder()
+          .AddOkButton(base::DoNothing())
+          .SetEnableInputProtection(true)
+          .Build();
+  auto host_false = std::make_unique<BubbleDialogModelHost>(
+      std::move(dialog_model_false), anchor_widget->GetContentsView(),
+      BubbleBorder::Arrow::TOP_RIGHT);
+  EXPECT_FALSE(host_false->ShouldAllowKeyEventsDuringInputProtection());
+}
+
 }  // namespace views
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ui/views/bubble/bubble_dialog_model_host_unittest.cc b/ui/views/bubble/bubble_dialog_model_host_unittest.cc
index 21c3c4d..aad0f37 100644
--- a/ui/views/bubble/bubble_dialog_model_host_unittest.cc
+++ b/ui/views/bubble/bubble_dialog_model_host_unittest.cc
@@ -480,4 +480,27 @@
   EXPECT_TRUE(bubble_widget->IsClosed());
 }
 
+TEST_F(BubbleDialogModelHostTest, ShouldAllowKeyEventsDuringInputProtection) {
+  std::unique_ptr<Widget> anchor_widget = CreateTestWidget(
+      Widget::InitParams::CLIENT_OWNS_WIDGET, Widget::InitParams::TYPE_WINDOW);
+  anchor_widget->Show();
+
+  std::unique_ptr<ui::DialogModel> dialog_model_default =
+      ui::DialogModel::Builder().AddOkButton(base::DoNothing()).Build();
+  auto host_default = std::make_unique<BubbleDialogModelHost>(
+      std::move(dialog_model_default), anchor_widget->GetContentsView(),
+      BubbleBorder::Arrow::TOP_RIGHT);
+  EXPECT_TRUE(host_default->ShouldAllowKeyEventsDuringInputProtection());
+
+  std::unique_ptr<ui::DialogModel> dialog_model_false =
+      ui::DialogModel::Builder()
+          .AddOkButton(base::DoNothing())
+          .SetEnableInputProtection(true)
+          .Build();
+  auto host_false = std::make_unique<BubbleDialogModelHost>(
+      std::move(dialog_model_false), anchor_widget->GetContentsView(),
+      BubbleBorder::Arrow::TOP_RIGHT);
+  EXPECT_FALSE(host_false->ShouldAllowKeyEventsDuringInputProtection());
+}
+
 }  // namespace views
Loading diff…

Original Bug Report

reported by [email protected]

Keyjacking bypass of Digital Identity safety interstitial on Desktop

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: A malicious website can potentially bypass the Digital Identity safety interstitial on desktop by tricking a user into accidentally accepting the dialog via rapid keystrokes. This is due to the ‘Continue’ button being focused by default and a lack of key-event input protection in the dialog host.

Affected files:

  • chrome/browser/ui/views/digital_credentials/digital_identity_safety_interstitial_controller_desktop.cc
  • ui/views/bubble/bubble_dialog_model_host.cc
  • ui/views/input_event_activation_protector.cc

Estimated timestamp from git blame: 2025-07-07

Background

The Digital Identity API (navigator.credentials.get({digital: ...})) is used by websites to request sensitive information from a user’s digital wallet. On desktop, Chrome displays a safety interstitial (a modal security warning) to ensure the user is aware of the risks before proceeding with a cross-device request (e.g., scanning a QR code).

The Issue

A potential vulnerability in the desktop implementation of this interstitial allows a malicious site to bypass the warning through a ‘keyjacking’ attack.

  1. Initially Focused Button: In DigitalIdentitySafetyInterstitialControllerDesktop::ShowInterstitialImpl, the dialog is constructed using ui::DialogModel and explicitly sets the initial focus to the ‘Continue’ button: .SetInitiallyFocusedField(kContinueButtonId); (digital_identity_safety_interstitial_controller_desktop.cc:162).

  2. Input Protection Bypass: The dialog is hosted by views::BubbleDialogModelHost, which inherits the default behavior of views::DialogDelegate::ShouldAllowKeyEventsDuringInputProtection(). This default implementation returns true (dialog_delegate.cc:249).

  3. Protector Logic: When a user interaction occurs, views::InputEventActivationProtector::IsPossiblyUnintendedInteraction() is called to prevent accidental interactions immediately after a dialog appears (within a ~500ms window). However, the protector explicitly allows key events to bypass this time-gate if the delegate’s allow_key_events is set to true:

    if (!event.IsMouseEvent() && !event.IsTouchEvent() && !event.IsGestureEvent()) {
      if (allow_key_events || !event.IsKeyEvent()) {
        return false; // Bypasses the 500ms safety interval
      }
    }
    

    (input_event_activation_protector.cc:60).

Consequently, any non-repeat key event (such as pressing Enter or Space) is processed immediately upon the dialog’s appearance, bypassing the guard designed to ensure informed user consent.

Potential Attack Scenario

  1. A malicious page induces the user to perform rapid keystrokes (e.g., via a fake ’type-to-win’ game or CAPTCHA requiring repeated Enter presses).
  2. During this activity, the page calls navigator.credentials.get({digital: ...}), consuming the transient user activation requirement.
  3. The browser process displays the Digital Identity safety interstitial.
  4. The ‘Continue’ button receives keyboard focus the instant the dialog appears due to the SetInitiallyFocusedField configuration.
  5. The user’s next keystroke (intended for the attacker’s page) is instead delivered to the focused ‘Continue’ button.
  6. The InputEventActivationProtector allows the interaction immediately, bypassing the safety interval.
  7. The dialog is dismissed, and the browser proceeds to the next step (e.g., displaying a QR code for a mobile wallet), having bypassed the safety warning without the user’s informed consent.

Note: These steps are suggested based on code analysis; our current environment does not support running live proof-of-concept code.

Impact

While this bypass allows skipping the browser’s safety warning, the overall impact is mitigated by the fact that the user must still complete the subsequent flow (e.g., scanning a QR code on a mobile device and approving the request in a wallet app). However, bypassing a primary security interstitial is considered a Medium (S2) severity issue in Chrome.

Suggested Fix

The host for the Digital Identity safety interstitial should ensure that ShouldAllowKeyEventsDuringInputProtection() returns false. This ensures that key events are subject to the same 500ms input protection interval as mouse events, preventing accidental dismissal.

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.

View on issue tracker