Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Extensions
DescriptionInappropriate implementation in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker505945112
Fix commit68be55bcd89e (chromium/src) +32/-30
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/extension_action_dispatcher.cc
modified
for
chrome/browser/extensions/extension_action_dispatcher.cc
modified

Files Changed

  • chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
  • chrome/browser/extensions/extension_action_dispatcher.cc
  • chrome/browser/extensions/extension_action_dispatcher.h
From 68be55bcd89e9ee541bb13d36dabb28836b5ce3f Mon Sep 17 00:00:00 2001
From: Tim Judkins <[email protected]>
Date: Tue, 28 Apr 2026 16:27:44 -0700
Subject: [PATCH] [Extensions] Pass UserGestureState explicitly for action events

Previously both the onClicked and onUserSettingsChanged events in the
action API both used the same helper to dispatch the events and in both
cases a user_gesture was set. This CL changes it so that the two callers
instead specify if a user gester should be set, as onUserSettingsChanged
doesn't need one.

Also adds to the related tests to validate the user gesture state, moves
the related function into the anonymous namespace as it's only called
from within this class and removes a <string> include that's not needed
in the header anymore.

Bug: 505945112
Change-Id: I16f98d7ecf8f22e50a12751f575fdcbdcd06dd37
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7797497
Reviewed-by: Devlin Cronin <[email protected]>
Commit-Queue: Tim <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1622101}
---

diff --git a/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc b/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
index f33042a6..37a4279e 100644
--- a/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
+++ b/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
@@ -526,6 +526,7 @@
            chrome.test.assertTrue(!!tab);
            chrome.test.assertTrue(tab.id > 0);
            chrome.test.assertTrue(tab.index > -1);
+           chrome.test.assertTrue(chrome.test.isProcessingUserGesture());
            chrome.test.notifyPass();
          });)";
 
@@ -1855,7 +1856,8 @@
          })";
   constexpr char kWorker[] =
       R"(chrome.action.onUserSettingsChanged.addListener(change => {
-           chrome.test.sendMessage(JSON.stringify(change));
+           const userGesture = chrome.test.isProcessingUserGesture();
+           chrome.test.sendMessage(JSON.stringify({change, userGesture}));
          });)";
 
   TestExtensionDir test_dir;
@@ -1878,10 +1880,10 @@
     return listener.message();
   };
 
-  EXPECT_EQ(R"({"isOnToolbar":true})",
+  EXPECT_EQ(R"({"change":{"isOnToolbar":true},"userGesture":false})",
             change_visibility_and_get_response(/*pinned_state=*/true));
 
-  EXPECT_EQ(R"({"isOnToolbar":false})",
+  EXPECT_EQ(R"({"change":{"isOnToolbar":false},"userGesture":false})",
             change_visibility_and_get_response(/*pinned_state=*/false));
 }
 
diff --git a/chrome/browser/extensions/extension_action_dispatcher.cc b/chrome/browser/extensions/extension_action_dispatcher.cc
index e7f9bdb..c34bb38 100644
--- a/chrome/browser/extensions/extension_action_dispatcher.cc
+++ b/chrome/browser/extensions/extension_action_dispatcher.cc
@@ -21,6 +21,29 @@
 
 namespace extensions {
 
+namespace {
+
+// Forwards events to the `context`'s event router.
+void DispatchEventToExtension(
+    content::BrowserContext* context,
+    const ExtensionId& extension_id,
+    events::HistogramValue histogram_value,
+    const std::string& event_name,
+    base::ListValue event_args,
+    EventRouter::UserGestureState user_gesture_state) {
+  auto* event_router = EventRouter::Get(context);
+  if (!event_router) {
+    return;
+  }
+
+  auto event = std::make_unique<Event>(histogram_value, event_name,
+                                       std::move(event_args), context);
+  event->user_gesture = user_gesture_state;
+  event_router->DispatchEventToExtension(extension_id, std::move(event));
+}
+
+}  // namespace
+
 static base::LazyInstance<
     BrowserContextKeyedAPIFactory<ExtensionActionDispatcher>>::DestructorAtExit
     g_extension_action_dispatcher_factory = LAZY_INSTANCE_INITIALIZER;
@@ -96,7 +119,8 @@
 
     DispatchEventToExtension(web_contents->GetBrowserContext(),
                              extension_action.extension_id(), histogram_value,
-                             event_name, std::move(args));
+                             event_name, std::move(args),
+                             EventRouter::UserGestureState::kEnabled);
   }
 }
 
@@ -137,22 +161,6 @@
   return extension_prefs_;
 }
 
-void ExtensionActionDispatcher::DispatchEventToExtension(
-    content::BrowserContext* context,
-    const ExtensionId& extension_id,
-    events::HistogramValue histogram_value,
-    const std::string& event_name,
-    base::ListValue event_args) {
-  if (!EventRouter::Get(context)) {
-    return;
-  }
-
-  auto event = std::make_unique<Event>(histogram_value, event_name,
-                                       std::move(event_args), context);
-  event->user_gesture = EventRouter::UserGestureState::kEnabled;
-  EventRouter::Get(context)->DispatchEventToExtension(extension_id,
-                                                      std::move(event));
-}
 
 void ExtensionActionDispatcher::Shutdown() {
   for (auto& observer : observers_) {
@@ -172,7 +180,8 @@
   args.Append(std::move(change));
   DispatchEventToExtension(browser_context_, extension_id,
                            events::ACTION_ON_USER_SETTINGS_CHANGED,
-                           "action.onUserSettingsChanged", std::move(args));
+                           "action.onUserSettingsChanged", std::move(args),
+                           EventRouter::UserGestureState::kNotEnabled);
 }
 
 }  // namespace extensions
diff --git a/chrome/browser/extensions/extension_action_dispatcher.h b/chrome/browser/extensions/extension_action_dispatcher.h
index f20fd7d..bf5e651 100644
--- a/chrome/browser/extensions/extension_action_dispatcher.h
+++ b/chrome/browser/extensions/extension_action_dispatcher.h
@@ -5,8 +5,6 @@
 #ifndef CHROME_BROWSER_EXTENSIONS_EXTENSION_ACTION_DISPATCHER_H_
 #define CHROME_BROWSER_EXTENSIONS_EXTENSION_ACTION_DISPATCHER_H_
 
-#include <string>
-
 #include "base/memory/raw_ptr.h"
 #include "base/observer_list.h"
 #include "base/scoped_observation.h"
@@ -103,13 +101,6 @@
   // Returns the associated extension prefs.
   ExtensionPrefs* GetExtensionPrefs();
 
-  // The DispatchEvent methods forward events to the `context`'s event router.
-  void DispatchEventToExtension(content::BrowserContext* context,
-                                const ExtensionId& extension_id,
-                                events::HistogramValue histogram_value,
-                                const std::string& event_name,
-                                base::ListValue event_args);
-
   // BrowserContextKeyedAPI implementation.
   void Shutdown() override;
   static const char* service_name() { return "ExtensionActionDispatcher"; }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc b/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
index f33042a6..37a4279e 100644
--- a/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
+++ b/chrome/browser/extensions/api/extension_action/extension_action_apitest.cc
@@ -526,6 +526,7 @@
            chrome.test.assertTrue(!!tab);
            chrome.test.assertTrue(tab.id > 0);
            chrome.test.assertTrue(tab.index > -1);
+           chrome.test.assertTrue(chrome.test.isProcessingUserGesture());
            chrome.test.notifyPass();
          });)";
 
@@ -1855,7 +1856,8 @@
          })";
   constexpr char kWorker[] =
       R"(chrome.action.onUserSettingsChanged.addListener(change => {
-           chrome.test.sendMessage(JSON.stringify(change));
+           const userGesture = chrome.test.isProcessingUserGesture();
+           chrome.test.sendMessage(JSON.stringify({change, userGesture}));
          });)";
 
   TestExtensionDir test_dir;
@@ -1878,10 +1880,10 @@
     return listener.message();
   };
 
-  EXPECT_EQ(R"({"isOnToolbar":true})",
+  EXPECT_EQ(R"({"change":{"isOnToolbar":true},"userGesture":false})",
             change_visibility_and_get_response(/*pinned_state=*/true));
 
-  EXPECT_EQ(R"({"isOnToolbar":false})",
+  EXPECT_EQ(R"({"change":{"isOnToolbar":false},"userGesture":false})",
             change_visibility_and_get_response(/*pinned_state=*/false));
 }
Loading diff…

Original Bug Report

reported by [email protected]

User gesture bypass via action.onUserSettingsChanged event

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The action.onUserSettingsChanged extension event is dispatched with an unconditional synthetic user gesture. This event can be triggered non-interactively, such as when an enterprise policy modifies an extension’s pinned state. Malicious extensions can potentially abuse this synthetic gesture to bypass user interaction requirements for restricted APIs like chrome.permissions.request().

Affected files:

  • chrome/browser/extensions/extension_action_dispatcher.cc
  • chrome/browser/ui/toolbar/toolbar_actions_model.cc

Estimated timestamp from git blame: 2026-01-25

Summary

When an extension’s pinned state on the toolbar changes, the browser dispatches the chrome.action.onUserSettingsChanged event to the extension. However, the internal method responsible for constructing this event unconditionally flags it as possessing a user gesture. Because this event can be triggered by non-interactive means (such as an enterprise administrator updating the ExtensionSettings policy to set toolbar_pin to "default_pinned"), a malicious extension can receive a synthetic user gesture without any actual user interaction.

This synthetic gesture propagates to the extension’s renderer process (including Manifest V3 Service Workers), potentially allowing the extension to invoke restricted APIs that normally require a user gesture, such as chrome.permissions.request() or chrome.sidePanel.open().

Root Cause

In chrome/browser/extensions/extension_action_dispatcher.cc, the OnActionPinnedStateChanged method is called when an extension’s pinned state changes (e.g., via ToolbarActionsModel::OnExtensionManagementSettingsChanged during a policy update).

OnActionPinnedStateChanged calls a helper method, DispatchEventToExtension, to construct and send the event. The vulnerability exists because DispatchEventToExtension unconditionally sets event->user_gesture = EventRouter::UserGestureState::kEnabled; at line 152:

void ExtensionActionDispatcher::DispatchEventToExtension(
    content::BrowserContext* context,
    const ExtensionId& extension_id,
    events::HistogramValue histogram_value,
    const std::string& event_name,
    base::ListValue event_args) {
  if (!EventRouter::Get(context)) {
    return;
  }

  auto event = std::make_unique<Event>(histogram_value, event_name,
                                       std::move(event_args), context);
  event->user_gesture = EventRouter::UserGestureState::kEnabled;  // Unconditional user gesture
  EventRouter::Get(context)->DispatchEventToExtension(extension_id,
                                                      std::move(event));
}

While this helper is also used for action.onClicked (which does involve a user gesture), reusing it for onUserSettingsChanged incorrectly grants a gesture to automated state changes.

Potential Reproduction Steps

Note: These are theoretical steps based on code analysis.

  1. An attacker creates a Manifest V3 extension that listens to chrome.action.onUserSettingsChanged.
  2. Inside the listener callback, the extension calls chrome.permissions.request({permissions: ['activeTab']}).
  3. The extension is installed on a managed device.
  4. The enterprise administrator pushes an update to the ExtensionSettings policy, setting toolbar_pin to "default_pinned" for the installed extension.
  5. ToolbarActionsModel::OnExtensionManagementSettingsChanged processes the policy and triggers the event dispatch.
  6. The extension receives the event with the synthetic user gesture, and the permissions request prompt appears unexpectedly without any user interaction.

Suggested Fix

Modify ExtensionActionDispatcher::DispatchEventToExtension to accept an additional boolean or enum parameter indicating whether a user gesture is present.

  • When called from DispatchExtensionActionClicked, pass true (or kEnabled).
  • When called from OnActionPinnedStateChanged, evaluate the context. If the change was initiated by an automated policy update, pass false (or kNotEnabled). Alternatively, as pinning/unpinning from the menu might not warrant bypassing gesture checks for restricted APIs anyway, consider always passing false for onUserSettingsChanged.

Evaluated with Chrome root at commit: 3acbde3302da0cb19488c22c0eb007c791207b4b


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.

View on issue tracker