Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker501739206
Fix commit6271562c38f9 (chromium/src) +335/-54
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • chrome/browser/extensions/extension_security_exploit_browsertest.cc
  • extensions/browser/event_router.cc
From 6271562c38f9ce7e44e8a3fea477b194924ffc04 Mon Sep 17 00:00:00 2001
From: Andrea Orru <[email protected]>
Date: Thu, 30 Apr 2026 18:59:34 -0700
Subject: [PATCH] [Extensions] Validate EventRouter messages for main thread listeners

Centralize and expand security validation for main thread event listener
IPCs in `EventRouter`. Previously, a compromised renderer process could
potentially spoof IPC messages to add or remove event listeners on
behalf of unauthorized extensions or URLs.

This introduces `ValidateMainThreadListenerOwner` to verify whether the
sending process is authorized to modify listeners for a specific
extension ID or URL. The validation logic distinguishes between active
listeners, which content and user script processes are permitted to
modify, and lazy listeners, which strictly require an actual extension
process.

Finally, trusted browser-internal callers that operate without an active
Mojo receiver context are updated to use newly added internal methods
(e.g., `AddLazyListenerForMainThreadImpl`) that bypass the renderer
process checks.

Bug: 501739206
Change-Id: Ib15bf959ba7efc44d123aba8508fe2adc75cb56d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7804723
Commit-Queue: Andrea Orru <[email protected]>
Reviewed-by: Devlin Cronin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1623668}
---

diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 4e88d82..6ff5c50 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -973,6 +973,17 @@
     // access to do things, while `spoofed_extension()` should not.
     InstallTestExtensions();
   }
+
+  // Binds the EventRouter interface as though `process` were the renderer
+  // sending the message.
+  mojo::AssociatedRemote<mojom::EventRouter> BindEventRouterForProcess(
+      content::RenderProcessHost* process) {
+    mojo::AssociatedRemote<mojom::EventRouter> event_router;
+    EventRouter::BindForRenderer(
+        process->GetID(),
+        event_router.BindNewEndpointAndPassDedicatedReceiver());
+    return event_router;
+  }
 };
 
 // Tests that attempting to add a main thread listener for the process of a
@@ -1326,4 +1337,111 @@
   }
 }
 
+// Tests that an extension process cannot add a filtered main thread lazy
+// listener on behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddFilteredListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  constexpr char kEventName[] = "test.eventName";
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->AddFilteredListenerForMainThread(
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      kEventName, base::DictValue(), /*add_lazy_listener=*/true);
+  event_router.FlushForTesting();
+
+  EXPECT_EQ("Tried to add an event listener for an unauthorized extension ID.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot remove a filtered main thread lazy
+// listener on behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    RemoveFilteredListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  EventRouter::Get(profile())->AddFilteredEventListener(
+      "test.eventName", main_frame_process,
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      nullptr, base::DictValue(), /*add_lazy_listener=*/true);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->RemoveFilteredListenerForMainThread(
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      "test.eventName", base::DictValue(), /*remove_lazy_listener=*/true);
+  event_router.FlushForTesting();
+
+  EXPECT_EQ(
+      "Tried to remove an event listener for an unauthorized extension ID.",
+      bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot add a main thread lazy listener on
+// behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddLazyListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->AddLazyListenerForMainThread(spoofed_extension_id(),
+                                             "test.eventName");
+  event_router.FlushForTesting();
+
+  EXPECT_EQ("Tried to add an event listener for an unauthorized extension ID.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot remove a main thread lazy listener on
+// behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    RemoveLazyListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  EventRouter::Get(profile())->listeners().LoadUnfilteredLazyListeners(
+      profile(), spoofed_extension_id(), /*is_for_service_worker=*/false,
+      {"test.eventName"});
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->RemoveLazyListenerForMainThread(spoofed_extension_id(),
+                                                "test.eventName");
+  event_router.FlushForTesting();
+
+  EXPECT_EQ(
+      "Tried to remove an event listener for an unauthorized extension ID.",
+      bad_message_observer.WaitForBadMessage());
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/event_router.cc b/extensions/browser/event_router.cc
index 0cb71362..827ff0e 100644
--- a/extensions/browser/event_router.cc
+++ b/extensions/browser/event_router.cc
@@ -120,6 +120,16 @@
     "Tried to remove an event listener for a service worker without a valid "
     "extension ID.";
 
+// A message when mojom::EventRouter::RemoveListenerForMainThread() or
+// RemoveListenerForServiceWorker() is called with an unauthorized extension ID.
+constexpr char kRemoveEventListenerWithUnauthorizedExtensionID[] =
+    "Tried to remove an event listener for an unauthorized extension ID.";
+
+// A message when mojom::EventRouter::RemoveListenerForMainThread() is called
+// with an unauthorized listener URL.
+constexpr char kRemoveEventListenerWithUnauthorizedListenerURL[] =
+    "Tried to remove an event listener for an unauthorized listener URL.";
+
 // Sends a notification about an event to the API activity monitor and the
 // ExtensionHost for |extension_id| on the UI thread. Can be called from any
 // thread.
@@ -393,6 +403,90 @@
       .Contains(extension_id);
 }
 
+bool EventRouter::ShouldIgnoreListenerMessageForUnloadedExtension(
+    const ExtensionId& extension_id) const {
+  return !extension_id.empty() && !IsExtensionEnabled(extension_id);
+}
+
+bool EventRouter::IsProcessAuthorizedForMainThreadExtensionListener(
+    const ExtensionId& extension_id,
+    RenderProcessHost& process) const {
+  // The process must be authorized to host the extension. This includes regular
+  // extension processes, but also processes that have had a content script or
+  // user script injected into them (since those run in the web page's process).
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/extension_security_exploit_browsertest.cc b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
index 4e88d82..6ff5c50 100644
--- a/chrome/browser/extensions/extension_security_exploit_browsertest.cc
+++ b/chrome/browser/extensions/extension_security_exploit_browsertest.cc
@@ -973,6 +973,17 @@
     // access to do things, while `spoofed_extension()` should not.
     InstallTestExtensions();
   }
+
+  // Binds the EventRouter interface as though `process` were the renderer
+  // sending the message.
+  mojo::AssociatedRemote<mojom::EventRouter> BindEventRouterForProcess(
+      content::RenderProcessHost* process) {
+    mojo::AssociatedRemote<mojom::EventRouter> event_router;
+    EventRouter::BindForRenderer(
+        process->GetID(),
+        event_router.BindNewEndpointAndPassDedicatedReceiver());
+    return event_router;
+  }
 };
 
 // Tests that attempting to add a main thread listener for the process of a
@@ -1326,4 +1337,111 @@
   }
 }
 
+// Tests that an extension process cannot add a filtered main thread lazy
+// listener on behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddFilteredListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  constexpr char kEventName[] = "test.eventName";
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->AddFilteredListenerForMainThread(
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      kEventName, base::DictValue(), /*add_lazy_listener=*/true);
+  event_router.FlushForTesting();
+
+  EXPECT_EQ("Tried to add an event listener for an unauthorized extension ID.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot remove a filtered main thread lazy
+// listener on behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    RemoveFilteredListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  EventRouter::Get(profile())->AddFilteredEventListener(
+      "test.eventName", main_frame_process,
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      nullptr, base::DictValue(), /*add_lazy_listener=*/true);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->RemoveFilteredListenerForMainThread(
+      mojom::EventListenerOwner::NewExtensionId(spoofed_extension_id()),
+      "test.eventName", base::DictValue(), /*remove_lazy_listener=*/true);
+  event_router.FlushForTesting();
+
+  EXPECT_EQ(
+      "Tried to remove an event listener for an unauthorized extension ID.",
+      bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot add a main thread lazy listener on
+// behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    AddLazyListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->AddLazyListenerForMainThread(spoofed_extension_id(),
+                                             "test.eventName");
+  event_router.FlushForTesting();
+
+  EXPECT_EQ("Tried to add an event listener for an unauthorized extension ID.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+// Tests that an extension process cannot remove a main thread lazy listener on
+// behalf of another extension.
+IN_PROC_BROWSER_TEST_F(
+    EventRouterExploitTest,
+    RemoveLazyListenerForMainThread_BadExtensionIdForExtensionPage) {
+  GURL test_page_url = active_extension().GetResourceURL("page.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(NavigateToURL(web_contents, test_page_url));
+
+  content::RenderProcessHost* main_frame_process =
+      web_contents->GetPrimaryMainFrame()->GetProcess();
+  mojo::AssociatedRemote<mojom::EventRouter> event_router =
+      BindEventRouterForProcess(main_frame_process);
+
+  EventRouter::Get(profile())->listeners().LoadUnfilteredLazyListeners(
+      profile(), spoofed_extension_id(), /*is_for_service_worker=*/false,
+      {"test.eventName"});
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  event_router->RemoveLazyListenerForMainThread(spoofed_extension_id(),
+                                                "test.eventName");
+  event_router.FlushForTesting();
+
+  EXPECT_EQ(
+      "Tried to remove an event listener for an unauthorized extension ID.",
+      bad_message_observer.WaitForBadMessage());
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/event_router_unittest.cc b/extensions/browser/event_router_unittest.cc
index 7907648..658e8262 100644
--- a/extensions/browser/event_router_unittest.cc
+++ b/extensions/browser/event_router_unittest.cc
@@ -619,7 +619,7 @@
   EXPECT_FALSE(router->IsExtensionEnabled(kExtensionId));
 
   // === Main Thread ===
-  router->AddLazyListenerForMainThread(kExtensionId, kEventName1);
+  router->AddLazyListenerForMainThreadImpl(kExtensionId, kEventName1);
   // The listener should not be registered.
   EXPECT_FALSE(router->ExtensionHasEventListener(kExtensionId, kEventName1));
   // The listener should be persisted to prefs.
@@ -653,26 +653,26 @@
   scoped_refptr<const Extension> extension = ExtensionBuilder("Test").Build();
 
   // Manually add orphaned events to prefs.
-  router->AddLazyListenerForMainThread(extension->id(),
-                                       "webRequest.onBeforeRequest/s1");
+  router->AddLazyListenerForMainThreadImpl(extension->id(),
+                                           "webRequest.onBeforeRequest/s1");
   router->AddLazyListenerForServiceWorker(
       extension->id(), Extension::GetBaseURLFromExtensionId(extension->id()),
       "webRequest.onBeforeRequest/s2");
 
-  router->AddLazyListenerForMainThread(extension->id(),
-                                       "webViewInternal.onMessage/s1");
+  router->AddLazyListenerForMainThreadImpl(extension->id(),
+                                           "webViewInternal.onMessage/s1");
   router->AddLazyListenerForServiceWorker(
       extension->id(), Extension::GetBaseURLFromExtensionId(extension->id()),
       "webViewInternal.onMessage/s2");
 
   // Add non-orphaned events to ensure they are kept.
-  router->AddLazyListenerForMainThread(extension->id(), "tabs.onCreated");
+  router->AddLazyListenerForMainThreadImpl(extension->id(), "tabs.onCreated");
   router->AddLazyListenerForServiceWorker(
       extension->id(), Extension::GetBaseURLFromExtensionId(extension->id()),
       "tabs.onRemoved");
 
-  router->AddLazyListenerForMainThread(extension->id(),
-                                       "webRequest.onActionIgnored");
+  router->AddLazyListenerForMainThreadImpl(extension->id(),
+                                           "webRequest.onActionIgnored");
   router->AddLazyListenerForServiceWorker(
       extension->id(), Extension::GetBaseURLFromExtensionId(extension->id()),
       "webRequest.onActionIgnored");
Loading diff…

Original Bug Report

reported by [email protected]

Compromised renderer can remove other extensions' webRequest listeners via EventRouter IPC

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: A compromised renderer can exploit missing process validation in extensions.mojom.EventRouter to add and remove a lazy listener on behalf of any other extension. Triggering this removal path causes WebRequestEventRouter to inadvertently delete the victim extension’s active webRequest listeners, silently bypassing security and content filtering policies.

Affected files:

  • extensions/browser/api/web_request/extension_web_request_event_router.cc
  • extensions/browser/event_router.cc
  • extensions/browser/api/web_request/web_request_api.cc

Estimated timestamp from git blame: 2026-02-25

A potential vulnerability in the Chrome Extensions system allows a compromised renderer process to silently remove the webRequest listeners of other extensions.

This issue stems from a lack of process-level authorization in the browser-side implementation of the extensions.mojom.EventRouter interface, combined with overly broad listener-matching logic in the WebRequest API.

Technical Analysis

1. Missing Authorization in EventRouter The extensions.mojom.EventRouter interface is exposed to all renderer processes. Methods like AddFilteredListenerForServiceWorker and RemoveFilteredListenerForServiceWorker accept an extension_id from the renderer. In extensions/browser/event_router.cc, the browser implementations (e.g., AddFilteredEventListener and RemoveFilteredEventListener) verify that the requested extension is enabled, but fail to verify that the calling process is authorized to host that extension (e.g., by checking ProcessMap).

2. Spoofing Lazy Listener Removal Because of the missing check, an attacker in a compromised renderer process can call AddFilteredListenerForServiceWorker for a victim extension ID (e.g., an enterprise policy enforcer) and set add_lazy_listener = true. The attacker can predict the victim’s sub-event name (e.g., webRequest.onBeforeRequest/s1).

Immediately after, the attacker calls RemoveFilteredListenerForServiceWorker with the same parameters and remove_lazy_listener = true. This successfully matches and removes the newly created lazy listener, triggering EventRouter::OnListenerRemoved with details.is_lazy = true.

3. Overly Broad Matching in WebRequestEventRouter The removal notification reaches WebRequestAPI::OnListenerRemoved (in extensions/browser/api/web_request/web_request_api.cc). Because details.is_lazy is true, it defers to WebRequestEventRouter::RemoveLazyListener.

This method attempts to clean up internal state by calling RemoveMatchingListeners (in extensions/browser/api/web_request/extension_web_request_event_router.cc) on both its active and inactive listener maps. It passes std::nullopt for both worker_thread_id and service_worker_version_id.

Crucially, RemoveMatchingListeners only verifies the extension_id and sub_event_name:

bool listener_matches =
    extension_id == id.extension_id &&
    sub_event_name == id.sub_event_name &&
    (!worker_thread_id || worker_thread_id == id.worker_thread_id) &&
    (!service_worker_version_id ||
     service_worker_version_id == id.service_worker_version_id);

Because the process ID is ignored during this matching process, and the worker IDs are nullopt, the router will find and remove the victim extension’s legitimate, active listener.

Impact

This vulnerability allows a compromised renderer to silently strip the network request interception capabilities of other extensions. This bypasses security-critical features such as enterprise request-blocking policies or ad blockers. The victim extension is not notified that its listeners have been removed, and the interception remains disabled.

Suggested Reproduction Steps

(Note: These are potential steps based on code analysis; our tooling cannot run live PoCs.)

  1. Identify an enabled victim extension $B$ with a webRequest.onBeforeRequest listener.
  2. From a compromised renderer process, bind the extensions.mojom.EventRouter interface.
  3. Call AddFilteredListenerForServiceWorker with extension_id = B, event_name = "webRequest.onBeforeRequest/s1", a broad filter, and add_lazy_listener = true.
  4. Call RemoveFilteredListenerForServiceWorker with the exact same parameters and remove_lazy_listener = true.
  5. The browser-side WebRequestEventRouter will inadvertently erase $B$’s legitimate active listener, allowing requests to proceed unfiltered.

Suggested Fix

Add robust validation at the Mojo boundary in EventRouter::AddFilteredEventListener, EventRouter::RemoveFilteredEventListener, and related listener registration methods. They should verify via ProcessMap::Contains(extension_id, process->GetID()) that the caller is genuinely authorized to act on behalf of the provided extension_id before adding or removing listeners.

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.

View on issue tracker