CVE-2026-11271
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.cc |
modified | |
Eventchrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h |
modified | |
PasswordCrossDomainConfirmationPopupViewViewschrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h |
modified |
Files Changed
chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.ccchrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.hchrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc
Patch
From f31b8a550d6edea5f8c5b93ce79d63cb10eef436 Mon Sep 17 00:00:00 2001 From: Talita Halboth <[email protected]> Date: Tue, 21 Apr 2026 07:41:51 -0700 Subject: [PATCH] Protect cross-domain password confirmation popup from keyjacking This CL adds an InputEventActivationProtector to the PasswordCrossDomainConfirmationPopupViewViews to mitigate keyjacking attacks. It prevents unintended confirmations by ignoring inputs that occur immediately after the popup is shown. Additionally, the initially focused view is changed from the confirm button to the cancel button to provide a safer default behavior. Tests are added to ensure the protector works as expected. Bug: b:501685207 Change-Id: If3d54721b7880545847bcca2392553ab7b3bcf3e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7777857 Reviewed-by: Oleksandr Tara <[email protected]> Commit-Queue: Talita Halboth <[email protected]> Reviewed-by: Mohamed Amir Yosef <[email protected]> Cr-Commit-Position: refs/heads/main@{#1618189} --- diff --git a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.cc b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.cc index bac6abd0..33c9669 100644 --- a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.cc +++ b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.cc @@ -22,6 +22,7 @@ #include "ui/base/l10n/l10n_util.h" #include "ui/base/metadata/metadata_impl_macros.h" #include "ui/base/ui_base_types.h" +#include "ui/events/event.h" #include "ui/gfx/geometry/insets.h" #include "ui/gfx/geometry/size.h" #include "ui/gfx/text_constants.h" @@ -50,7 +51,8 @@ base::OnceClosure cancel_callback) : autofill::PopupBaseView(controller, parent_widget, - views::Widget::InitParams::Activatable::kYes) { + views::Widget::InitParams::Activatable::kYes), + confirmation_callback_(std::move(confirmation_callback)) { SetBackground(views::CreateSolidBackground(ui::kColorDropdownBackground)); auto* layout_provider = ChromeLayoutProvider::Get(); @@ -105,17 +107,22 @@ .SetBetweenChildSpacing(layout_provider->GetDistanceMetric( views::DISTANCE_RELATED_BUTTON_HORIZONTAL)) .Build()); - controls->AddChildView(views::Builder<views::MdTextButton>() - .SetText(l10n_util::GetStringUTF16(IDS_CANCEL)) - .SetStyle(ui::ButtonStyle::kDefault) - .SetCallback(std::move(cancel_callback)) - .Build()); + auto* cancel_button = controls->AddChildView( + views::Builder<views::MdTextButton>() + .SetText(l10n_util::GetStringUTF16(IDS_CANCEL)) + .SetStyle(ui::ButtonStyle::kDefault) + .SetCallback(std::move(cancel_callback)) + .SetID(static_cast<int>(PopupViewId::kCancelButton)) + .Build()); auto* confirmation_button = controls->AddChildView( views::Builder<views::MdTextButton>() .SetText(l10n_util::GetStringUTF16( IDS_PASSWORD_CROSS_DOMAIN_FILLING_CONFIRMATION_CONFIRM_BUTTON_LABEL)) .SetStyle(ui::ButtonStyle::kProminent) - .SetCallback(std::move(confirmation_callback)) + .SetCallback(base::BindRepeating( + &PasswordCrossDomainConfirmationPopupViewViews::OnConfirm, + base::Unretained(this))) + .SetID(static_cast<int>(PopupViewId::kConfirmButton)) .Build()); confirmation_button->GetViewAccessibility().SetName(base::JoinString( {controller->GetTitleText(), controller->GetBodyText(), @@ -126,7 +133,18 @@ layout_provider->GetDistanceMetric( DISTANCE_STANDALONE_BUBBLE_PREFERRED_WIDTH)); SetPreferredSize(gfx::Size(popup_width, GetHeightForWidth(popup_width))); - SetInitiallyFocusedView(confirmation_button); + SetInitiallyFocusedView(cancel_button); +} + +void PasswordCrossDomainConfirmationPopupViewViews::OnConfirm( + const ui::Event& event) { + if (input_protector_.IsPossiblyUnintendedInteraction( + event, /*allow_key_events=*/false)) { + return; + } + if (confirmation_callback_) { + std::move(confirmation_callback_).Run(); + } } PasswordCrossDomainConfirmationPopupViewViews:: @@ -147,6 +165,7 @@ void PasswordCrossDomainConfirmationPopupViewViews::Show() { DoShow(); + input_protector_.VisibilityChanged(true); } BEGIN_METADATA(PasswordCrossDomainConfirmationPopupViewViews) diff --git a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h index b2968750..bee87d3 100644 --- a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h +++ b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.h @@ -15,6 +15,11 @@ #include "chrome/browser/ui/passwords/password_cross_domain_confirmation_popup_view.h" #include "chrome/browser/ui/views/autofill/popup/popup_base_view.h" #include "ui/base/metadata/metadata_header_macros.h" +#include "ui/views/input_event_activation_protector.h" + +namespace ui { +class Event; +} // The views implementation of the `PasswordCrossDomainConfirmationPopupView`. class PasswordCrossDomainConfirmationPopupViewViews @@ -24,6 +29,12 @@ autofill::PopupBaseView) public: + enum class PopupViewId { + kNone = 0, + kConfirmButton, + kCancelButton, + }; + PasswordCrossDomainConfirmationPopupViewViews( base::WeakPtr<PasswordCrossDomainConfirmationPopupControllerInterface> controller, @@ -50,6 +61,10 @@ } private: + void OnConfirm(const ui::Event& event); + + base::OnceClosure confirmation_callback_; + views::InputEventActivationProtector input_protector_; base::WeakPtrFactory<PasswordCrossDomainConfirmationPopupViewViews> weak_factory_{this}; }; diff --git a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc index 87b7bb0..bde00eb 100644 --- a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc +++ b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc @@ -16,6 +16,9 @@ #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" #include "ui/base/l10n/l10n_util.h" +#include "ui/events/event.h" +#include "ui/views/controls/button/md_text_button.h" +#include "ui/views/test/button_test_api.h" #include "ui/views/widget/widget.h" #include "url/gurl.h" @@ -69,6 +72,13 @@ PasswordCrossDomainConfirmationPopupViewBrowsertest() = default; ~PasswordCrossDomainConfirmationPopupViewBrowsertest() override = default; + public: + bool confirm_called() const { return confirm_called_; } + void OnConfirm() { confirm_called_ = true; } + + protected: + bool confirm_called_ = false; + void SetUpOnMainThread() override { PopupPixelTest::SetUpOnMainThread(); @@ -98,7 +108,11 @@ views::Widget::GetWidgetForNativeWindow( browser()->window()->GetNativeWindow()), /*domain=*/GURL("https://a.com"), - /*password_hostname=*/u"b.com", base::DoNothing(), base::DoNothing()); + /*password_hostname=*/u"b.com", + base::BindOnce( + &PasswordCrossDomainConfirmationPopupViewBrowsertest::OnConfirm, + base::Unretained(this)), + base::DoNothing()); } }; @@ -107,6 +121,44 @@ ShowAndVerifyUi(); } +IN_PROC_BROWSER_TEST_P(PasswordCrossDomainConfirmationPopupViewBrowsertest, + InitialFocusOnCancelButton) { + ShowUi("InitialFocusOnCancelButton"); + + views::MdTextButton* cancel_button = + views::AsViewClass<views::MdTextButton>(view()->GetViewByID( + static_cast<int>(PasswordCrossDomainConfirmationPopupViewViews:: + PopupViewId::kCancelButton))); + ASSERT_THAT(cancel_button, testing::NotNull()); + + views::View* initially_focused_view = view()->GetInitiallyFocusedView(); + ASSERT_THAT(initially_focused_view, testing::NotNull());
Regression Test / PoC
diff --git a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc
index 87b7bb0..bde00eb 100644
--- a/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc
+++ b/chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views_browsertest.cc
@@ -16,6 +16,9 @@
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/l10n/l10n_util.h"
+#include "ui/events/event.h"
+#include "ui/views/controls/button/md_text_button.h"
+#include "ui/views/test/button_test_api.h"
#include "ui/views/widget/widget.h"
#include "url/gurl.h"
@@ -69,6 +72,13 @@
PasswordCrossDomainConfirmationPopupViewBrowsertest() = default;
~PasswordCrossDomainConfirmationPopupViewBrowsertest() override = default;
+ public:
+ bool confirm_called() const { return confirm_called_; }
+ void OnConfirm() { confirm_called_ = true; }
+
+ protected:
+ bool confirm_called_ = false;
+
void SetUpOnMainThread() override {
PopupPixelTest::SetUpOnMainThread();
@@ -98,7 +108,11 @@
views::Widget::GetWidgetForNativeWindow(
browser()->window()->GetNativeWindow()),
/*domain=*/GURL("https://a.com"),
- /*password_hostname=*/u"b.com", base::DoNothing(), base::DoNothing());
+ /*password_hostname=*/u"b.com",
+ base::BindOnce(
+ &PasswordCrossDomainConfirmationPopupViewBrowsertest::OnConfirm,
+ base::Unretained(this)),
+ base::DoNothing());
}
};
@@ -107,6 +121,44 @@
ShowAndVerifyUi();
}
+IN_PROC_BROWSER_TEST_P(PasswordCrossDomainConfirmationPopupViewBrowsertest,
+ InitialFocusOnCancelButton) {
+ ShowUi("InitialFocusOnCancelButton");
+
+ views::MdTextButton* cancel_button =
+ views::AsViewClass<views::MdTextButton>(view()->GetViewByID(
+ static_cast<int>(PasswordCrossDomainConfirmationPopupViewViews::
+ PopupViewId::kCancelButton)));
+ ASSERT_THAT(cancel_button, testing::NotNull());
+
+ views::View* initially_focused_view = view()->GetInitiallyFocusedView();
+ ASSERT_THAT(initially_focused_view, testing::NotNull());
+ EXPECT_EQ(initially_focused_view, cancel_button);
+}
+
+IN_PROC_BROWSER_TEST_P(PasswordCrossDomainConfirmationPopupViewBrowsertest,
+ InputEventActivationProtectorWorks) {
+ ShowUi("InputProtectorWorks");
+
+ views::MdTextButton* confirm_button =
+ views::AsViewClass<views::MdTextButton>(view()->GetViewByID(
+ static_cast<int>(PasswordCrossDomainConfirmationPopupViewViews::
+ PopupViewId::kConfirmButton)));
+ ASSERT_THAT(confirm_button, testing::NotNull());
+
+ ui::MouseEvent immediate_click(ui::EventType::kMousePressed, gfx::Point(),
+ gfx::Point(), base::TimeTicks::Now(),
+ ui::EF_LEFT_MOUSE_BUTTON, 0);
+ views::test::ButtonTestApi(confirm_button).NotifyClick(immediate_click);
+ EXPECT_FALSE(confirm_called());
+
+ ui::MouseEvent delayed_click(
+ ui::EventType::kMousePressed, gfx::Point(), gfx::Point(),
+ base::TimeTicks::Now() + base::Seconds(2), ui::EF_LEFT_MOUSE_BUTTON, 0);
+ views::test::ButtonTestApi(confirm_button).NotifyClick(delayed_click);
+ EXPECT_TRUE(confirm_called());
+}
+
INSTANTIATE_TEST_SUITE_P(
All,
PasswordCrossDomainConfirmationPopupViewBrowsertest,
Original Bug Report
Potential credential disclosure via keyjacking in cross-domain password confirmation popup
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 without the Chrome Security team.
Overview: The cross-domain password filling confirmation popup lacks an InputEventActivationProtector and auto-focuses the ‘Confirm’ button upon creation. This allows an attacker to bypass the security warning and steal cross-origin credentials by social-engineering a user into rapidly pressing or holding the ‘Enter’ key during manual fallback autofill.
Affected files:
chrome/browser/ui/views/passwords/password_cross_domain_confirmation_popup_view_views.ccchrome/browser/ui/passwords/password_cross_domain_confirmation_popup_controller_impl.cc
Estimated timestamp from git blame: 2025-01-08
Description
The PasswordCrossDomainConfirmationPopupViewViews is a security-sensitive UI component designed to require explicit user consent before a saved credential from one origin (e.g., victim.example) is filled into another origin (e.g., attacker.example). However, this popup is potentially vulnerable to ‘keyjacking’ because it lacks standard dialog interaction protections.
The popup view is constructed as an activatable widget (views::Widget::InitParams::Activatable::kYes) and immediately sets the default focus to the prominent ‘Confirm’ button (SetInitiallyFocusedView(confirmation_button)). Crucially, because the view inherits from autofill::PopupBaseView (a views::WidgetDelegateView) and not views::DialogDelegateView, it does not benefit from the built-in InputEventActivationProtector typically provided by DialogClientView.
Exploitation Mechanism
When a user selects a cross-domain password suggestion (either from the manual-fallback picker or a grouped-affiliation suggestion) using the keyboard (e.g., pressing ‘Enter’), the autofill system synchronously processes the request. It detects the cross-domain nature and immediately creates and shows the confirmation popup.
Because the new popup is activatable, it steals native OS focus synchronously before the original autofill popup has fully closed. If an attacker can social-engineer the user into double-tapping or holding the ‘Enter’ key, the subsequent rapid or repeated key events are routed directly to the newly focused ‘Confirm’ button.
In the Views framework, ButtonController::OnKeyPressed does not inherently block event.is_repeat() events or enforce a debounce. On Windows, Linux, and ChromeOS, PlatformStyle::kReturnClicksFocusedControl is true, meaning the ‘Enter’ key triggers the button on press, not release. (On macOS, a similar vulnerability exists using the ‘Space’ key). Consequently, the auto-repeating or rapid second key press immediately executes the ‘Confirm’ callback without any delay, bypassing the security warning entirely.
Impact
A malicious website can potentially obtain a user’s saved credentials for another origin without requiring a renderer compromise. This relies on social engineering the user into specific keyboard interactions (e.g., “Hold Enter to verify”) while selecting a credential.
Suggested Reproduction Steps
Note: These are potential steps based on code analysis; automated tooling has not yet executed a live proof-of-concept.
- Ensure biometric authentication before filling is disabled (the default setting on desktop).
- Save a password for
victim.example. - Navigate to a malicious page
attacker.examplecontaining a login form. - The page instructs the user: “Right-click the box, select your account, and hold Enter to verify.”
- The user triggers manual-fallback autofill, selects the
victim.exampleentry, and holds the ‘Enter’ key. - The first key press accepts the suggestion. The cross-domain popup appears and steals focus.
- The subsequent repeating key events immediately trigger the focused ‘Confirm’ button.
- The credential is filled into the attacker’s page and extracted via JavaScript.
Suggested Fix
- Implement Input Protection: The view should utilize an
InputEventActivationProtectorto ignore input events for a short period (e.g., 500ms) after the popup becomes visible. This is the standard defense against clickjacking and keyjacking in security dialogs. This could be achieved by refactoring the popup to useDialogDelegateViewor by manually instantiating and checking anInputEventActivationProtectorin the view’s event handlers. - Adjust Default Focus: Consider altering
SetInitiallyFocusedViewso that the ‘Confirm’ button is not focused by default. Focusing the ‘Cancel’ button or nothing at all would prevent accidental confirmation via rapid ‘Enter’ presses.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.