Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Extensions
DescriptionImproper input validation in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassInput Validation
Tracker523237501
Fix commit973f4839b1c1 (chromium/src) +369/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
ExtensionsMenuSiteAccessToggle
chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
modified
ExtensionsMenuSiteAccessToggle
chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
modified
if
chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
modified
BrowserWindowInterface
chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
modified
HoverButton
chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
modified

Files Changed

  • chrome/browser/ui/views/extensions/BUILD.gn
  • chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
  • chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
  • chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
  • chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
  • chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
  • ui/views/controls/button/toggle_button.h
From 973f4839b1c15385af59b545f358a4fa2b97b581 Mon Sep 17 00:00:00 2001
From: Justin Lulejian <[email protected]>
Date: Fri, 18 Sep 2026 17:32:13 -0700
Subject: [PATCH] [Extensions] Protect site access toggle from unintended clicks

Previously, when the Extensions Menu site permissions sub-page was open
during a cross-origin navigation, OnPageNavigation() called
OpenMainPage() to rebuild the main menu page after navigation commit.
Because the rebuilt ExtensionsMenuEntryView bound the post-navigation
origin into site_access_toggle_ and the toggle lacked
views::InputEventActivationProtector, a user click aimed at the sub-page
row could land on the newly rebuilt toggle and inadvertently grant site
access to the newly committed origin. Additionally, cross-origin
navigations while the main page was open updated menu entries in place
without resetting input protection.

After this change, immediate user clicks on the site access toggle or
allow button after view creation, widget attachment, visibility changes,
layout shifts, or cross-origin navigations are ignored during the input
protection window, avoiding accidental permission changes while
preserving legitimate post-navigation interactions.

Fixed: 523237501
Bug: 563454001
Change-Id: I771218d7ddf65f3a971f1c1453e736874b0bf35a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8415512
Reviewed-by: Dana Fried <[email protected]>
Auto-Submit: Justin Lulejian <[email protected]>
Commit-Queue: Justin Lulejian <[email protected]>
Reviewed-by: Benjamin Keen <[email protected]>
Reviewed-by: Eva Su <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1701247}
---
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc b/chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
index 1c9a74f..a6856ce7 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
+++ b/chrome/browser/ui/views/extensions/extensions_menu_entry_view.cc
@@ -4,6 +4,8 @@
 
 #include "chrome/browser/ui/views/extensions/extensions_menu_entry_view.h"
 
+#include "base/functional/bind.h"
+#include "base/functional/callback.h"
 #include "base/metrics/user_metrics.h"
 #include "base/metrics/user_metrics_action.h"
 #include "base/strings/strcat.h"
@@ -20,6 +22,7 @@
 #include "ui/base/metadata/metadata_impl_macros.h"
 #include "ui/base/mojom/menu_source_type.mojom.h"
 #include "ui/base/ui_base_features.h"
+#include "ui/events/event.h"
 #include "ui/strings/grit/ui_strings.h"
 #include "ui/views/accessibility/view_accessibility.h"
 #include "ui/views/controls/button/button.h"
@@ -27,7 +30,10 @@
 #include "ui/views/controls/button/toggle_button.h"
 #include "ui/views/controls/highlight_path_generator.h"
 #include "ui/views/controls/label.h"
+#include "ui/views/input_event_activation_protector.h"
+#include "ui/views/metadata/view_factory.h"
 #include "ui/views/vector_icons.h"
+#include "ui/views/view_utils.h"
 
 namespace {
 
@@ -83,6 +89,83 @@
 
 }  // namespace
 
+// TODO(crbug.com/563454001): Revisit this once Benjamin's (bkeen@) widget-level
+// input protection system is landed.
+// A toggle button in `ExtensionsMenuEntryView` that grants or revokes site
+// access for an extension. It uses `views::InputEventActivationProtector` to
+// prevent unintended interactions after view creation, visibility changes, or
+// cross-origin navigations.
+class ExtensionsMenuSiteAccessToggle : public views::ToggleButton {
+  METADATA_HEADER(ExtensionsMenuSiteAccessToggle, views::ToggleButton)
+
+ public:
+  ExtensionsMenuSiteAccessToggle() { ResetInputProtection(); }
+  ExtensionsMenuSiteAccessToggle(const ExtensionsMenuSiteAccessToggle&) =
+      delete;
+  ExtensionsMenuSiteAccessToggle& operator=(
+      const ExtensionsMenuSiteAccessToggle&) = delete;
+  ~ExtensionsMenuSiteAccessToggle() override = default;
+
+  // Resets the protection window in `views::InputEventActivationProtector` when
+  // the displayed site origin changes across a navigation.
+  void ResetInputProtection() {
+    input_protector_.MaybeUpdateViewProtectedTimeStamp(/*force=*/true);
+  }
+
+  // views::View:
+  void VisibilityChanged(views::View* starting_from, bool is_visible) override {
+    views::ToggleButton::VisibilityChanged(starting_from, is_visible);
+    input_protector_.VisibilityChanged(is_visible);
+  }
+
+  void AddedToWidget() override {
+    views::ToggleButton::AddedToWidget();
+    if (IsDrawn()) {
+      input_protector_.VisibilityChanged(/*is_visible=*/true);
+    }
+  }
+
+  void RemovedFromWidget() override {
+    views::ToggleButton::RemovedFromWidget();
+    input_protector_.VisibilityChanged(/*is_visible=*/false);
+  }
+
+  bool GetNeedsNotificationWhenVisibleBoundsChange() const override {
+    return true;
+  }
+
+  void OnVisibleBoundsChanged() override {
+    input_protector_.MaybeUpdateViewProtectedTimeStamp();
+  }
+
+  void OnBoundsChanged(const gfx::Rect& previous_bounds) override {
+    views::ToggleButton::OnBoundsChanged(previous_bounds);
+    input_protector_.MaybeUpdateViewProtectedTimeStamp();
+  }
+
+  // views::Button:
+  void NotifyClick(const ui::Event& event) override {
+    if (input_protector_.IsPossiblyUnintendedInteraction(
+            event, /*allow_key_events=*/false)) {
+      return;
+    }
+    views::ToggleButton::NotifyClick(event);
+  }
+
+ private:
+  views::InputEventActivationProtector input_protector_;
+};
+
+BEGIN_METADATA(ExtensionsMenuSiteAccessToggle)
+END_METADATA
+
+BEGIN_VIEW_BUILDER(/* no export */,
+                   ExtensionsMenuSiteAccessToggle,
+                   views::ToggleButton)
+END_VIEW_BUILDER
+
+DEFINE_VIEW_BUILDER(/* no export */, ExtensionsMenuSiteAccessToggle)
+
 DEFINE_ELEMENT_IDENTIFIER_VALUE(kExtensionsMenuEntryViewElementId);
 
 ExtensionsMenuEntryView::ExtensionsMenuEntryView(
@@ -90,7 +173,8 @@
     bool is_enterprise,
     ToolbarActionViewModel* view_model,
     views::Button::PressedCallback action_button_callback,
-    base::RepeatingCallback<void(bool)> site_access_toggle_callback,
+    base::RepeatingCallback<void(const url::Origin&, bool)>
+        site_access_toggle_callback,
     views::Button::PressedCallback site_permissions_button_callback)
     : extension_id_(view_model->GetId()) {
   CHECK(base::FeatureList::IsEnabled(
@@ -147,7 +231,7 @@
                               kExtensionsMenuButtonRadius))
                       .SetFocusBehavior(views::View::FocusBehavior::ALWAYS),
                   // Site access toggle.
-                  views::Builder<views::ToggleButton>()
+                  views::Builder<ExtensionsMenuSiteAccessToggle>()
                       .CopyAddressTo(&site_access_toggle_)
                       .SetProperty(
                           views::kMarginsKey,
@@ -155,14 +239,26 @@
                       .SetAccessibleName(l10n_util::GetStringFUTF16(
                           IDS_EXTENSIONS_MENU_EXTENSION_SITE_ACCESS_TOGGLE_ACCESSIBLE_NAME,
                           view_model->GetActionName()))
+                      // Pass `this` to access `origin()` at click time rather
+                      // than binding a static copy of the origin.
                       .SetCallback(base::BindRepeating(
-                          [](views::ToggleButton* toggle_button,
-                             base::RepeatingCallback<void(bool)>
+                          [](ExtensionsMenuEntryView* entry_view,
+                             const base::RepeatingCallback<void(
+                                 const url::Origin&, bool)>&
                                  site_access_toggle_callback) {
                             site_access_toggle_callback.Run(
-                                toggle_button->GetIsOn());
+                                entry_view->origin(),
+                                entry_view->site_access_toggle_->GetIsOn());
                           },
-                          site_access_toggle_, site_access_toggle_callback)),
+                          // Passing `base::Unretained(this)` is safe because
+                          // `site_access_toggle_` is a child view of `this`
+                          // (`ExtensionsMenuEntryView`). The view hierarchy
+                          // guarantees that `views::View::~View()` destroys all
+                          // child views and their bound callbacks before `this`
+                          // is deallocated, so the callback can never outlive
+                          // `this`.
+                          base::Unretained(this),
+                          std::move(site_access_toggle_callback))),
                   // Context menu button.
                   views::Builder<HoverButton>(
                       std::make_unique<HoverButton>(
@@ -238,6 +334,14 @@
 
 void ExtensionsMenuEntryView::Update(
     ExtensionsMenuViewModel::MenuEntryState entry_state) {
+  if (origin_ != entry_state.origin) {
+    origin_ = entry_state.origin;
+    if (auto* toggle = views::AsViewClass<ExtensionsMenuSiteAccessToggle>(
+            site_access_toggle_)) {
+      toggle->ResetInputProtection();
+    }
+  }
+
   site_access_toggle_->SetVisible(
       entry_state.site_access_toggle.status !=
       ExtensionsMenuViewModel::ControlState::Status::kHidden);
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_entry_view.h b/chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
index 97a5e55..062b8a8 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
+++ b/chrome/browser/ui/views/extensions/extensions_menu_entry_view.h
@@ -7,6 +7,7 @@
 
 #include <string>
 
+#include "base/functional/callback_forward.h"
 #include "base/memory/raw_ptr.h"
 #include "chrome/browser/ui/extensions/extensions_menu_view_model.h"
 #include "chrome/browser/ui/views/extensions/extension_context_menu_controller.h"
@@ -15,6 +16,7 @@
 #include "ui/views/controls/button/button.h"
 #include "ui/views/layout/flex_layout_view.h"
 #include "ui/views/view.h"
+#include "url/origin.h"
 
 class BrowserWindowInterface;
 class HoverButton;
@@ -39,7 +41,8 @@
       bool is_enterprise,
       ToolbarActionViewModel* view_model,
       views::Button::PressedCallback action_button_callback,
-      base::RepeatingCallback<void(bool)> site_access_toggle_callback,
+      base::RepeatingCallback<void(const url::Origin&, bool)>
+          site_access_toggle_callback,
       views::Button::PressedCallback site_permissions_button_callback);
   ExtensionsMenuEntryView(const ExtensionsMenuEntryView&) = delete;
   ExtensionsMenuEntryView& operator=(const ExtensionsMenuEntryView&) = delete;
@@ -57,6 +60,7 @@
       ExtensionsMenuViewModel::ControlState button_state);
 
   const extensions::ExtensionId& extension_id() { return extension_id_; }
+  const url::Origin& origin() const { return origin_; }
 
   // Accessors for testing.
   bool IsContextMenuRunningForTesting() const;
@@ -90,6 +94,9 @@
   // The id of the extension the entry corresponds to.
   const extensions::ExtensionId extension_id_;
 
+  // The origin of the site currently displayed in this menu entry.
+  url::Origin origin_;
+
   // Controller responsible for showing the context menu for an extension.
   std::unique_ptr<ExtensionContextMenuController> context_menu_controller_;
 
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
index f570d94..c0633b86 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
+++ b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
@@ -89,6 +89,8 @@
 DEFINE_UI_CLASS_PROPERTY_TYPE(ExtensionIdWrapper*)
 DEFINE_OWNED_UI_CLASS_PROPERTY_KEY(ExtensionIdWrapper, kExtensionIdKey)
 
+// TODO(crbug.com/563454001): Revisit this once Benjamin's (bkeen@) widget-level
+// input protection system is landed.
 // A button in the extensions menu requesting access section that grants one
 // time site access to the extension. It uses an input event activation
 // protector to prevent unintended clicks.
@@ -106,6 +108,26 @@
     input_protector_.VisibilityChanged(is_visible);
   }
 
+  void AddedToWidget() override {
+    views::MdTextButton::AddedToWidget();
+    if (IsDrawn()) {
+      input_protector_.VisibilityChanged(/*is_visible=*/true);
+    }
+  }
+
+  void RemovedFromWidget() override {
+    views::MdTextButton::RemovedFromWidget();
+    input_protector_.VisibilityChanged(/*is_visible=*/false);
+  }
+
+  bool GetNeedsNotificationWhenVisibleBoundsChange() const override {
+    return true;
+  }
+
+  void OnVisibleBoundsChanged() override {
+    input_protector_.MaybeUpdateViewProtectedTimeStamp();
+  }
+
   void OnBoundsChanged(const gfx::Rect& previous_bounds) override {
     views::MdTextButton::OnBoundsChanged(previous_bounds);
     input_protector_.MaybeUpdateViewProtectedTimeStamp();
@@ -245,8 +267,7 @@
       base::BindRepeating(&ExtensionsMenuHandler::OnActionButtonClicked,
                           base::Unretained(menu_handler_), extension_id),
       base::BindRepeating(&ExtensionsMenuHandler::OnExtensionToggleSelected,
-                          base::Unretained(menu_handler_), extension_id,
-                          entry_state.origin),
+                          base::Unretained(menu_handler_), extension_id),
       base::BindRepeating(&ExtensionsMenuHandler::OpenSitePermissionsPage,
                           base::Unretained(menu_handler_), extension_id));
   item->Update(entry_state);
diff --git a/ui/views/controls/button/toggle_button.h b/ui/views/controls/button/toggle_button.h
index ed0c066eb..e4952b9 100644
--- a/ui/views/controls/button/toggle_button.h
+++ b/ui/views/controls/button/toggle_button.h
@@ -65,6 +65,7 @@
  protected:
   // views::View:
   void OnThemeChanged() override;
+  void OnBoundsChanged(const gfx::Rect& previous_bounds) override;
 
   // views::Button:
   void NotifyClick(const ui::Event& event) override;
@@ -98,7 +99,6 @@
 
   // views::View:
   bool CanAcceptEvent(const ui::Event& event) override;
-  void OnBoundsChanged(const gfx::Rect& previous_bounds) override;
 
   // Button:
   void PaintButtonContents(gfx::Canvas* canvas) override;
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
index 65a4bae..80579fc8 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
+++ b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
@@ -4,6 +4,7 @@
 
 #include "chrome/browser/ui/views/extensions/extensions_menu_main_page_view.h"
 
+#include "base/command_line.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "chrome/browser/extensions/browsertest_util.h"
 #include "chrome/browser/extensions/chrome_test_extension_loader.h"
@@ -52,6 +53,7 @@
 #include "extensions/test/test_extension_dir.h"
 #include "net/dns/mock_host_resolver.h"
 #include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
 #include "ui/base/interaction/state_observer.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/page_transition_types.h"
@@ -59,6 +61,7 @@
 #include "ui/views/animation/ink_drop.h"
 #include "ui/views/controls/button/toggle_button.h"
 #include "ui/views/view_utils.h"
+#include "ui/views/views_switches.h"
 
 namespace {
 
@@ -196,6 +199,7 @@
 
   // ExtensionsToolbarUITest:
   void ShowUi(const std::string& name) override;
+  void SetUpCommandLine(base::CommandLine* command_line) override;
 
  private:
   base::test::ScopedFeatureList scoped_feature_list_;
@@ -207,6 +211,13 @@
       extensions_features::kExtensionsMenuAccessControl);
 }
 
+void ExtensionsMenuMainPageViewInteractiveUITest::SetUpCommandLine(
+    base::CommandLine* command_line) {
+  ExtensionsToolbarUITest::SetUpCommandLine(command_line);
+  command_line->AppendSwitch(
+      views::switches::kDisableInputEventActivationProtectionForTesting);
+}
+
 void ExtensionsMenuMainPageViewInteractiveUITest::ShowMenu() {
   menu_coordinator()->Show(views::BubbleAnchor(extensions_button()),
                            GetExtensionsToolbarDesktop());
@@ -514,6 +525,14 @@
   ExtensionsMenuMainPageViewInteractiveTest& operator=(
       const ExtensionsMenuMainPageViewInteractiveTest&) = delete;
 
+  // InteractiveBrowserTestMixin<extensions::ExtensionBrowserTest>:
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    InteractiveBrowserTestMixin<
+        extensions::ExtensionBrowserTest>::SetUpCommandLine(command_line);
+    command_line->AppendSwitch(
+        views::switches::kDisableInputEventActivationProtectionForTesting);
+  }
+
   // Installs extension with `name` and `host_permission`.
   scoped_refptr<const extensions::Extension>
   InstallExtensionWithHostPermissions(const std::string& name,
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc b/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
index d743001..f1d38c5 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
index 65a4bae..80579fc8 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
+++ b/chrome/browser/ui/views/extensions/extensions_menu_main_page_view_interactive_uitest.cc
@@ -4,6 +4,7 @@
 
 #include "chrome/browser/ui/views/extensions/extensions_menu_main_page_view.h"
 
+#include "base/command_line.h"
 #include "base/test/metrics/histogram_tester.h"
 #include "chrome/browser/extensions/browsertest_util.h"
 #include "chrome/browser/extensions/chrome_test_extension_loader.h"
@@ -52,6 +53,7 @@
 #include "extensions/test/test_extension_dir.h"
 #include "net/dns/mock_host_resolver.h"
 #include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
 #include "ui/base/interaction/state_observer.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/base/page_transition_types.h"
@@ -59,6 +61,7 @@
 #include "ui/views/animation/ink_drop.h"
 #include "ui/views/controls/button/toggle_button.h"
 #include "ui/views/view_utils.h"
+#include "ui/views/views_switches.h"
 
 namespace {
 
@@ -196,6 +199,7 @@
 
   // ExtensionsToolbarUITest:
   void ShowUi(const std::string& name) override;
+  void SetUpCommandLine(base::CommandLine* command_line) override;
 
  private:
   base::test::ScopedFeatureList scoped_feature_list_;
@@ -207,6 +211,13 @@
       extensions_features::kExtensionsMenuAccessControl);
 }
 
+void ExtensionsMenuMainPageViewInteractiveUITest::SetUpCommandLine(
+    base::CommandLine* command_line) {
+  ExtensionsToolbarUITest::SetUpCommandLine(command_line);
+  command_line->AppendSwitch(
+      views::switches::kDisableInputEventActivationProtectionForTesting);
+}
+
 void ExtensionsMenuMainPageViewInteractiveUITest::ShowMenu() {
   menu_coordinator()->Show(views::BubbleAnchor(extensions_button()),
                            GetExtensionsToolbarDesktop());
@@ -514,6 +525,14 @@
   ExtensionsMenuMainPageViewInteractiveTest& operator=(
       const ExtensionsMenuMainPageViewInteractiveTest&) = delete;
 
+  // InteractiveBrowserTestMixin<extensions::ExtensionBrowserTest>:
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    InteractiveBrowserTestMixin<
+        extensions::ExtensionBrowserTest>::SetUpCommandLine(command_line);
+    command_line->AppendSwitch(
+        views::switches::kDisableInputEventActivationProtectionForTesting);
+  }
+
   // Installs extension with `name` and `host_permission`.
   scoped_refptr<const extensions::Extension>
   InstallExtensionWithHostPermissions(const std::string& name,
diff --git a/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc b/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
index d743001..f1d38c5 100644
--- a/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
+++ b/chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view_browsertest.cc
@@ -4,10 +4,14 @@
 
 #include "chrome/browser/ui/views/extensions/extensions_menu_site_permissions_page_view.h"
 
+#include <vector>
+
+#include "base/memory/scoped_refptr.h"
 #include "chrome/browser/extensions/chrome_test_extension_loader.h"
 #include "chrome/browser/ui/tabs/tab_strip_model.h"
 #include "chrome/browser/ui/views/extensions/extensions_menu_coordinator.h"
 #include "chrome/browser/ui/views/extensions/extensions_menu_delegate_desktop.h"
+#include "chrome/browser/ui/views/extensions/extensions_menu_entry_view.h"
 #include "chrome/browser/ui/views/extensions/extensions_menu_main_page_view.h"
 #include "chrome/browser/ui/views/extensions/extensions_request_access_button.h"
 #include "chrome/browser/ui/views/extensions/extensions_toolbar_browsertest.h"
@@ -19,13 +23,22 @@
 #include "extensions/browser/permissions/site_permissions_helper.h"
 #include "extensions/browser/permissions_manager.h"
 #include "extensions/browser/test_extension_registry_observer.h"
+#include "extensions/common/extension.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/test/permissions_manager_waiter.h"
 #include "extensions/test/test_extension_dir.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
 #include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/events/event.h"
+#include "ui/events/event_utils.h"
+#include "ui/gfx/geometry/point_f.h"
+#include "ui/views/bubble/bubble_dialog_delegate_view.h"
 #include "ui/views/controls/button/radio_button.h"
 #include "ui/views/controls/button/toggle_button.h"
 #include "ui/views/test/views_test_utils.h"
+#include "ui/views/widget/widget.h"
+#include "ui/views/widget/widget_delegate.h"
 #include "url/gurl.h"
 
 namespace {
@@ -592,3 +605,194 @@
   EXPECT_FALSE(on_site_button->GetChecked());
   EXPECT_FALSE(on_all_sites_button->GetChecked());
 }
+
+// Verifies that if a cross-origin navigation occurs while the site permissions
+// sub-page is open and causes
+// `ExtensionsMenuDelegateDesktop::OnPageNavigation()` to rebuild back to
+// `ExtensionsMenuMainPageView`, an immediate in-flight click on the rebuilt
+// version does not inadvertently grant site access to the newly committed
+// origin (crbug.com/523237501).
+IN_PROC_BROWSER_TEST_F(
+    ExtensionsSitePermissionsPageViewBrowserTest,
+    SitePermissionsPageNavigationDoesNotGrantNewOriginAccessOnImmediateToggleClick) {
+  // Install an extension requesting `<all_urls>` host permissions.
+  scoped_refptr<const extensions::Extension> extension =
+      InstallExtensionWithHostPermissions(
+          /*name=*/"Test Extension", /*host_permissions=*/{"<all_urls>"});
+  ASSERT_TRUE(extension);
+
+  const GURL origin_a_url = embedded_test_server()->GetURL(
+      /*hostname=*/"a.com", /*relative_url=*/"/title1.html");
+  const GURL origin_b_url = embedded_test_server()->GetURL(
+      /*hostname=*/"b.com", /*relative_url=*/"/title1.html");
+
+  // Navigate to the initial origin.
+  NavigateAndCommit(origin_a_url);
+
+  // Set the extension's user site access to on-click (withheld) on all sites so
+  // that the user must explicitly grant access per site.
+  extensions::PermissionsManagerWaiter waiter(
+      PermissionsManager::Get(profile()));
+  SitePermissionsHelper(profile()).UpdateSiteAccess(
+      *extension, web_contents(),
+      /*new_access=*/PermissionsManager::UserSiteAccess::kOnClick,
+      /*expected_origin=*/
+      web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+  {
+    SCOPED_TRACE("Waiting for initial on click site access update");
+    waiter.WaitForExtensionPermissionsUpdate();
+  }
+  ASSERT_EQ(GetUserSiteAccess(*extension, origin_a_url),
+            PermissionsManager::UserSiteAccess::kOnClick);
+  ASSERT_EQ(GetUserSiteAccess(*extension, origin_b_url),
+            PermissionsManager::UserSiteAccess::kOnClick);
+
+  // Open the Extensions Menu and navigate to the extension's site permissions
+  // sub-page while on `origin_a_url`.
+  ShowSitePermissionsPage(extension->id());
+  ASSERT_FALSE(IsMainPageOpened());
+  ASSERT_TRUE(IsSitePermissionsPageOpened(extension->id()));
+
+  // Trigger a cross-origin same-tab navigation to `origin_b_url` while the site
+  // permissions sub-page is open.
+  // `ExtensionsMenuDelegateDesktop::OnPageNavigation()` synchronously switches
+  // the menu back to a freshly constructed `ExtensionsMenuMainPageView`.
+  NavigateAndCommit(origin_b_url);
+  ASSERT_TRUE(IsMainPageOpened());
+  ASSERT_FALSE(IsSitePermissionsPageOpened(extension->id()));
+  LayoutMenuIfNecessary();
+
+  // Locate the newly rebuilt `ExtensionsMenuEntryView` on the main page.
+  ASSERT_TRUE(main_page());
+  std::vector<ExtensionsMenuEntryView*> entries = main_page()->GetMenuEntries();
+  ASSERT_EQ(entries.size(), 1u);
+  ExtensionsMenuEntryView* entry_view = entries.front();
+  views::ToggleButton* site_access_toggle =
+      entry_view->site_access_toggle_for_testing();
+  ASSERT_TRUE(site_access_toggle);
+  EXPECT_FALSE(site_access_toggle->GetIsOn());
+
+  // Simulate an in-flight user mouse click landing immediately on the rebuilt
+  // `site_access_toggle` right after the sub-page to main-page swap.
+  ui::MouseEvent press_event(ui::EventType::kMousePressed,
+                             /*location=*/gfx::PointF(1, 1),
+                             /*root_location=*/gfx::PointF(1, 1),
+                             /*time_stamp=*/ui::EventTimeForNow(),
+                             /*flags=*/ui::EF_LEFT_MOUSE_BUTTON,
+                             /*changed_button_flags=*/ui::EF_LEFT_MOUSE_BUTTON);
+  ui::MouseEvent release_event(
+      ui::EventType::kMouseReleased, /*location=*/gfx::PointF(1, 1),
+      /*root_location=*/gfx::PointF(1, 1),
+      /*time_stamp=*/ui::EventTimeForNow(),
+      /*flags=*/ui::EF_LEFT_MOUSE_BUTTON,
+      /*changed_button_flags=*/ui::EF_LEFT_MOUSE_BUTTON);
+  site_access_toggle->OnMousePressed(press_event);
+  site_access_toggle->OnMouseReleased(release_event);
+
+  // Verify that `origin_b_url` remains at on-click (withheld) and has not been
+  // granted on-site or on-all-sites access.
+  EXPECT_FALSE(site_access_toggle->GetIsOn());
+  EXPECT_EQ(GetUserSiteAccess(*extension, origin_b_url),
+            PermissionsManager::UserSiteAccess::kOnClick);
+}
+
+// Verifies that if a cross-origin navigation occurs while
+// `ExtensionsMenuMainPageView` is open and
+// `ExtensionsMenuDelegateDesktop::OnPageNavigation()` updates the menu entries,
+// an immediate in-flight click on the site access toggle does not inadvertently
+// grant site access to the newly committed origin (crbug.com/523237501).
+// This test is placed in `ExtensionsSitePermissionsPageViewBrowserTest` rather
+// than `ExtensionsMenuMainPageViewBrowserTest` because
+// `ExtensionsMenuMainPageViewBrowserTest` disables input protection via the
+// `views::switches::kDisableInputEventActivationProtectionForTesting` command
+// line switch for all of its tests.
+IN_PROC_BROWSER_TEST_F(
+    ExtensionsSitePermissionsPageViewBrowserTest,
+    MainPageNavigationDoesNotGrantNewOriginAccessOnImmediateToggleClick) {
+  // Install an extension requesting `<all_urls>` host permissions.
+  scoped_refptr<const extensions::Extension> extension =
+      InstallExtensionWithHostPermissions(
+          /*name=*/"Test Extension", /*host_permissions=*/{"<all_urls>"});
+  ASSERT_TRUE(extension);
+
+  const GURL origin_a_url = embedded_test_server()->GetURL(
+      /*hostname=*/"a.com", /*relative_url=*/"/title1.html");
+  const GURL origin_b_url = embedded_test_server()->GetURL(
+      /*hostname=*/"b.com", /*relative_url=*/"/title1.html");
+
+  // Navigate to the initial origin (`origin_a_url`).
+  NavigateAndCommit(origin_a_url);
+
+  // Set the extension's user site access to on-click (withheld) on all sites so
+  // that the user must explicitly grant access per site.
+  extensions::PermissionsManagerWaiter waiter(
+      PermissionsManager::Get(profile()));
+  SitePermissionsHelper(profile()).UpdateSiteAccess(
+      *extension, web_contents(),
+      /*new_access=*/PermissionsManager::UserSiteAccess::kOnClick,
+      /*expected_origin=*/
+      web_contents()->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+  {
+    SCOPED_TRACE("Waiting for initial on click site access update");
+    waiter.WaitForExtensionPermissionsUpdate();
+  }
+  ASSERT_EQ(GetUserSiteAccess(*extension, origin_a_url),
+            PermissionsManager::UserSiteAccess::kOnClick);
+  ASSERT_EQ(GetUserSiteAccess(*extension, origin_b_url),
+            PermissionsManager::UserSiteAccess::kOnClick);
+
+  // Open the Extensions Menu main page while on `origin_a_url`.
+  menu_coordinator()->Show(
+      /*anchor=*/views::BubbleAnchor(extensions_button()),
+      /*extensions_container_views=*/extensions_container());
+  if (views::Widget* menu_widget =
+          menu_coordinator()->GetExtensionsMenuWidget()) {
+    if (auto* bubble_delegate =
+            menu_widget->widget_delegate()->AsBubbleDialogDelegate()) {
+      bubble_delegate->set_close_on_deactivate(/*close=*/false);
+    }
+  }
+  ASSERT_TRUE(IsMainPageOpened());
+  LayoutMenuIfNecessary();
+
+  // Trigger a cross-origin same-tab navigation to `origin_b_url` while the main
+  // page is open. `ExtensionsMenuDelegateDesktop::OnPageNavigation()` updates
+  // the existing `ExtensionsMenuEntryView` in place and resets input
+  // protection.
+  NavigateAndCommit(origin_b_url);
+  ASSERT_TRUE(IsMainPageOpened());
+  LayoutMenuIfNecessary();
+
+  // Locate `ExtensionsMenuEntryView` on the main page.
+  ASSERT_TRUE(main_page());
+  std::vector<ExtensionsMenuEntryView*> entries = main_page()->GetMenuEntries();
+  ASSERT_EQ(entries.size(), 1u);
+  ExtensionsMenuEntryView* entry_view = entries.front();
+  views::ToggleButton* site_access_toggle =
+      entry_view->site_access_toggle_for_testing();
+  ASSERT_TRUE(site_access_toggle);
+  EXPECT_FALSE(site_access_toggle->GetIsOn());
+
+  // Simulate an in-flight user mouse click landing immediately on
+  // `site_access_toggle` right after the cross-origin navigation update.
+  ui::MouseEvent press_event(ui::EventType::kMousePressed,
+                             /*location=*/gfx::PointF(1, 1),
+                             /*root_location=*/gfx::PointF(1, 1),
+                             /*time_stamp=*/ui::EventTimeForNow(),
+                             /*flags=*/ui::EF_LEFT_MOUSE_BUTTON,
+                             /*changed_button_flags=*/ui::EF_LEFT_MOUSE_BUTTON);
+  ui::MouseEvent release_event(
+      ui::EventType::kMouseReleased, /*location=*/gfx::PointF(1, 1),
+      /*root_location=*/gfx::PointF(1, 1),
+      /*time_stamp=*/ui::EventTimeForNow(),
+      /*flags=*/ui::EF_LEFT_MOUSE_BUTTON,
+      /*changed_button_flags=*/ui::EF_LEFT_MOUSE_BUTTON);
+  site_access_toggle->OnMousePressed(press_event);
+  site_access_toggle->OnMouseReleased(release_event);
+
+  // Verify that `origin_b_url` remains at on-click (withheld) and
+  // `site_access_toggle` remains off.
+  EXPECT_FALSE(site_access_toggle->GetIsOn());
+  EXPECT_EQ(GetUserSiteAccess(*extension, origin_b_url),
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Extensions Menu expected_origin race defense bypass via sub-page navigation

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 potential race condition in the desktop extensions menu allows a site or a colluding extension to bypass the origin-matching defense when site permissions are updated. When a navigation occurs while the site-permissions sub-page is open, the menu is rebuilt and binds the new post-navigation origin to the site access toggle callbacks. An in-flight user click can then land on the rebuilt toggle, potentially persisting unauthorized site access permissions for the new origin.

Affected files:

  • chrome/browser/ui/views/extensions/extensions_menu_delegate_desktop.cc
  • chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
  • chrome/browser/ui/extensions/extensions_menu_view_model.cc
  • extensions/browser/permissions/site_permissions_helper.cc

Estimated timestamp from git blame: 2026-06-11

Summary

A potential race condition in the desktop Extensions Menu allows a site to bypass the expected_origin defense used to protect site access toggle permissions. When same-tab navigation commits while the user has the per-extension site-permissions sub-page open, the menu is rebuilt back to the main page. This rebuild binds the newly committed post-navigation origin to the reconstructed toggle callbacks. Consequently, an in-flight click from the user can land on the rebuilt toggle, bypassing the origin checks and granting the extension unauthorized host access to the new origin.

Note: The following steps and analysis are based on static code review; our automated analysis tooling does not currently have the capability to execute dynamic proof-of-concept tests.

Vulnerability Analysis

In chrome/browser/ui/views/extensions/extensions_menu_delegate_desktop.cc, when a page navigation commits, the handler OnPageNavigation() is invoked:

void ExtensionsMenuDelegateDesktop::OnPageNavigation() {
  DCHECK(current_page_);

  // Update main page if it is open.
  auto* main_page = GetMainPage(current_page_.view());
  if (main_page) {
    UpdateMainPage(main_page);
    return;
  }

  // If the site permissions page is open, navigate back to the main page.
  // This prevents the extension from gaining permissions to a new origin
  // that the user didn't intend to authorize during a race condition.
  auto* site_permissions_page = GetSitePermissionsPage(current_page_.view());
  CHECK(site_permissions_page);
  OpenMainPage();
}

If the main page was open, UpdateMainPage() updates it in place and does not rebind the callbacks. However, if the site-permissions sub-page was open, the delegate calls OpenMainPage(). This destroys the old view and instantiates a brand-new main page view (ExtensionsMenuMainPageView).

During this instantiation, OpenMainPage() -> PopulateMainPage() -> InsertMenuEntry() calls GetMenuEntryState() to construct view models for each extension:

// chrome/browser/ui/extensions/extensions_menu_view_model.cc
entry_state.origin =
    web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin();

Because the page navigation has already committed, entry_state.origin evaluates to the new post-navigation origin.

The main page view then registers the toggle callback with this post-navigation origin bound by value:

// chrome/browser/ui/views/extensions/extensions_menu_main_page_view.cc
auto item = std::make_unique<ExtensionsMenuEntryView>(
    browser_, entry_state.is_enterprise, action_model,
    base::BindRepeating(&ExtensionsMenuHandler::OnActionButtonClicked, ...),
    base::BindRepeating(&ExtensionsMenuHandler::OnExtensionToggleSelected,
                        base::Unretained(menu_handler_), extension_id,
                        entry_state.origin),     // <-- post-navigation origin bound by value
    base::BindRepeating(&ExtensionsMenuHandler::OpenSitePermissionsPage, ...));

When the user’s click (which was aimed at a control on the sub-page) lands on the newly rendered toggle button on the main page, OnExtensionToggleSelected() is executed, calling ExtensionsMenuViewModel::GrantSiteAccess() with the post-navigation origin.

Inside GrantSiteAccess(), the safety check is performed:

// chrome/browser/ui/extensions/extensions_menu_view_model.cc
if (!target_origin.IsSameOriginWith(
        web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin())) {
  return;
}

Because both target_origin (bound in the recreated callback) and the active web_contents origin are evaluated post-navigation, this check is tautological and evaluates to true. The check in SitePermissionsHelper::UpdateSiteAccess() is defeated in the same manner, allowing the permission modification to succeed.

Additionally, unlike the ExtensionsMenuAllowButton, the views::ToggleButton in ExtensionsMenuEntryView does not carry an InputEventActivationProtector to suppress stale clicks resulting from abrupt layout changes.

Potential Trigger Steps

  1. The user installs an extension with withheld host permissions (e.g. “on click”).
  2. The user visits https://attacker.example, opens the extensions menu, and navigates to the extension’s site-permissions sub-page.
  3. While the user is about to click a setting on the sub-page, the attacker site triggers a navigation to a victim origin (e.g. https://victim.example).
  4. The navigation commits, and the menu handler rebuilds the main page, binding the toggle to https://victim.example.
  5. The user’s in-flight click lands on the newly rendered toggle.
  6. The extension is persistently granted host permissions for https://victim.example without the user’s consent or visual acknowledgment.

Suggested Fix

  1. Add Click Protection: Implement a views::InputEventActivationProtector on the site access toggle button inside ExtensionsMenuEntryView to discard input events that occur immediately after layout/view state transitions.
  2. Safer Transition on Navigation: Instead of creating a new main page with a freshly queried post-navigation origin on same-tab navigation, the bubble could either close entirely or retain a reference to the origin that was displayed when the sub-page was opened, validating any subsequent actions against that origin.

Evaluated with Chrome root at commit: b2fea2e31df308d0f04e4ae47def4c4f939ee141


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