Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassUAF
Tracker517094892
Fix commit131714ab59d2 (chromium/src) +132/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
extensions/browser/event_router_unittest.cc
modified

Files Changed

  • extensions/browser/event_router_unittest.cc
  • chrome/browser/extensions/api/cookies/cookies_apitest.cc
  • extensions/browser/events/event_dispatch_helper.cc
From 131714ab59d24d7206f34fef918d94363df696b2 Mon Sep 17 00:00:00 2001
From: Andrea Orru <andreaorru@chromium.org>
Date: Mon, 14 Sep 2026 17:34:26 -0700
Subject: [PATCH] [Extensions] Respect runtime host restrictions for URL-scoped events

Events scoped to a URL (Event::event_url) were only checked against the
extension's host permissions (PermissionSet::HasEffectiveAccessToURL()),
ignoring the restrictions applied on top of them at runtime:
ExtensionSettings runtime_blocked_hosts and user-blocked sites. An
extension with "cookies" and broad host permissions therefore received
cookies.onChanged, including HttpOnly cookie values, for hosts blocked
by runtime_blocked_hosts, even though
cookies.get()/getAll()/set()/remove() rejected those hosts.

Also reject URLs blocked by the user or by policy, mirroring
PermissionsData::GetPageAccess() including its exemption of component
extensions from policy host restrictions.

Bug: 517094892
Change-Id: I6629617ff88789a479f01ebb73c68cf13259d4d5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8404081
Auto-Submit: Andrea Orru <andreaorru@chromium.org>
Commit-Queue: Andrea Orru <andreaorru@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1697683}
---
diff --git a/extensions/browser/events/event_dispatch_helper.cc b/extensions/browser/events/event_dispatch_helper.cc
index d77c824..657f24bd 100644
--- a/extensions/browser/events/event_dispatch_helper.cc
+++ b/extensions/browser/events/event_dispatch_helper.cc
@@ -23,6 +23,7 @@
 #include "extensions/common/manifest_handlers/incognito_info.h"
 #include "extensions/common/mojom/context_type.mojom.h"
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
+#include "extensions/common/mojom/manifest.mojom-shared.h"
 #include "extensions/common/permissions/permissions_data.h"
 
 using content::BrowserContext;
@@ -55,6 +56,27 @@
                                                                     &context);
 }
 
+// Returns whether `extension` may receive an event scoped to `url`. It needs
+// host permissions for `url`, and `url` must not be blocked by the user or by
+// enterprise policy.
+// TODO(andreaorru): This matches effective hosts, so an extension whose only
+// match for `url` is a content script pattern still receives the event, while
+// PermissionsData::GetPageAccess(), which the cookies API uses, matches
+// explicit hosts and would refuse it. Consider sharing one method.
+bool CanAccessEventURL(const Extension& extension, const GURL& url) {
+  const PermissionsData& permissions_data = *extension.permissions_data();
+  if (!permissions_data.active_permissions().HasEffectiveAccessToURL(url)) {
+    return false;
+  }
+  // Component extensions ship with the browser, so neither user nor admin
+  // host restrictions apply to them, as in PermissionsData::GetPageAccess().
+  if (extension.location() == mojom::ManifestLocation::kComponent) {
+    return true;
+  }
+  return !permissions_data.IsUrlBlockedByUser(url) &&
+         !permissions_data.IsPolicyBlockedHost(url);
+}
+
 // Returns true if the listener has permission to receive the given `event`.
 //
 // For extensions, this checks for host permissions to the event's URL and
@@ -73,9 +95,7 @@
     // to access that URL.
     if (!event.event_url.is_empty() &&
         event.event_url.GetHost() != extension->id() &&  // event for self is ok
-        !extension->permissions_data()
-             ->active_permissions()
-             .HasEffectiveAccessToURL(event.event_url)) {
+        !CanAccessEventURL(*extension, event.event_url)) {
       return false;
     }
     // Secondly, if the event is for incognito mode, the Extension must be
diff --git a/chrome/browser/extensions/api/cookies/cookies_apitest.cc b/chrome/browser/extensions/api/cookies/cookies_apitest.cc
index 894b8560c..eeeda14 100644
--- a/chrome/browser/extensions/api/cookies/cookies_apitest.cc
+++ b/chrome/browser/extensions/api/cookies/cookies_apitest.cc
@@ -7,9 +7,12 @@
 #include "base/memory/raw_ptr.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
+#include "base/test/test_future.h"
 #include "build/build_config.h"
 #include "chrome/browser/extensions/api/cookies/cookies_api.h"
 #include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/extensions/extension_management_test_util.h"
+#include "chrome/browser/extensions/extension_with_management_policy_apitest.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/profiles/profile_destroyer.h"
 #include "components/content_settings/core/common/content_settings.h"
@@ -27,6 +30,8 @@
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
 #include "mojo/public/cpp/bindings/remote.h"
+#include "net/cookies/canonical_cookie.h"
+#include "net/cookies/cookie_options.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/default_handlers.h"
 #include "services/network/public/mojom/cookie_manager.mojom.h"
@@ -515,4 +520,68 @@
   )");
 }
 
+using CookiesPolicyApiTest = ExtensionApiTestWithManagementPolicy;
+
+// Tests that cookies.onChanged is not dispatched for hosts blocked by the
+// ExtensionSettings `runtime_blocked_hosts` policy. The cookies API functions
+// already reject such hosts. Regression test for https://crbug.com/517094892.
+IN_PROC_BROWSER_TEST_F(CookiesPolicyApiTest,
+                       OnChangedRespectsPolicyBlockedHosts) {
+  {
+    ExtensionManagementPolicyUpdater pref(&policy_provider_);
+    pref.AddPolicyBlockedHost("*", "*://blocked.example");
+  }
+
+  static constexpr char kManifest[] = R"({
+    "name": "Cookies policy test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "permissions": ["cookies"],
+    "host_permissions": ["<all_urls>"],
+    "background": {"service_worker": "background.js"}
+  })";
+  static constexpr char kBackgroundJs[] = R"(
+    chrome.cookies.onChanged.addListener((changeInfo) => {
+      chrome.test.sendMessage('changed:' + changeInfo.cookie.domain);
+    });
+    chrome.test.sendMessage('ready');
+  )";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackgroundJs);
+
+  ExtensionTestMessageListener ready_listener("ready");
+  ASSERT_TRUE(LoadExtension(test_dir.UnpackedPath()));
+  ASSERT_TRUE(ready_listener.WaitUntilSatisfied());
+
+  ExtensionTestMessageListener blocked_listener("changed:blocked.example");
+  ExtensionTestMessageListener allowed_listener("changed:allowed.example");
+
+  auto set_cookie = [this](const GURL& url) {
+    std::unique_ptr<net::CanonicalCookie> cookie = net::CanonicalCookie::Create(
+        url, "sid=secret; Path=/; HttpOnly", base::Time::Now(),
+        /*server_time=*/std::nullopt, /*cookie_partition_key=*/std::nullopt,
+        net::CookieSourceType::kOther, /*status=*/nullptr);
+    ASSERT_TRUE(cookie);
+    base::test::TestFuture<net::CookieAccessResult> future;
+    profile()
+        ->GetDefaultStoragePartition()
+        ->GetCookieManagerForBrowserProcess()
+        ->SetCanonicalCookie(*cookie, url,
+                             net::CookieOptions::MakeAllInclusive(),
+                             future.GetCallback());
+    EXPECT_TRUE(future.Take().status.IsInclude());
+  };
+
+  // Cookie changes reach the extension in the order they happen, so once the
+  // event for the allowed host has arrived, the event for the blocked host
+  // would have arrived too if it had leaked.
+  set_cookie(GURL("http://blocked.example/"));
+  set_cookie(GURL("http://allowed.example/"));
+
+  EXPECT_TRUE(allowed_listener.WaitUntilSatisfied());
+  EXPECT_FALSE(blocked_listener.was_satisfied());
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/event_router_unittest.cc b/extensions/browser/event_router_unittest.cc
index 0bdd77e..b1fe5d0 100644
--- a/extensions/browser/event_router_unittest.cc
+++ b/extensions/browser/event_router_unittest.cc
@@ -40,6 +40,9 @@
 #include "extensions/common/features/simple_feature.h"
 #include "extensions/common/mojom/context_type.mojom.h"
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
+#include "extensions/common/permissions/permissions_data.h"
+#include "extensions/common/url_pattern.h"
+#include "extensions/common/url_pattern_set.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom-blink-forward.h"
 
@@ -1813,4 +1816,41 @@
             dispatched_event->user_gesture);
 }
 
+// Tests that an event scoped to a URL isn't dispatched for hosts blocked by
+// enterprise policy, even if the extension has host permissions for them.
+// Regression test for https://crbug.com/517094892.
+TEST_F(EventRouterDispatchTest, EventURLRespectsPolicyBlockedHosts) {
+  const ExtensionId ext1 = "ext1";
+  RegisterTestApiFeature(StaticFeatureData(kTestEventFeatureData));
+
+  scoped_refptr<const Extension> extension =
+      ExtensionBuilder("test extension")
+          .SetID(ext1)
+          .AddHostPermission("<all_urls>")
+          .Build();
+  ExtensionRegistry::Get(browser_context())->AddEnabled(extension);
+
+  URLPatternSet policy_blocked_hosts;
+  policy_blocked_hosts.AddPattern(
+      URLPattern(URLPattern::SCHEME_ALL, "*://blocked.example/*"));
+  extension->permissions_data()->SetPolicyHostRestrictions(
+      policy_blocked_hosts, /*policy_allowed_hosts=*/URLPatternSet());
+
+  TestEventRouterObserver observer(event_router());
+  event_router()->AddEventListenerForTesting(kTestEventName, process(), ext1);
+
+  auto create_event = [&](const char* url) {
+    auto event = std::make_unique<Event>(events::FOR_TEST, kTestEventName,
+                                         base::ListValue(), browser_context());
+    event->event_url = GURL(url);
+    return event;
+  };
+
+  event_router()->BroadcastEvent(create_event("http://blocked.example/"));
+  EXPECT_EQ(0u, observer.dispatched_events().size());
+
+  event_router()->BroadcastEvent(create_event("http://allowed.example/"));
+  EXPECT_EQ(1u, observer.dispatched_events().size());
+}
+
 }  // namespace extensions
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/cookies/cookies_apitest.cc b/chrome/browser/extensions/api/cookies/cookies_apitest.cc
index 894b8560c..eeeda14 100644
--- a/chrome/browser/extensions/api/cookies/cookies_apitest.cc
+++ b/chrome/browser/extensions/api/cookies/cookies_apitest.cc
@@ -7,9 +7,12 @@
 #include "base/memory/raw_ptr.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
+#include "base/test/test_future.h"
 #include "build/build_config.h"
 #include "chrome/browser/extensions/api/cookies/cookies_api.h"
 #include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/extensions/extension_management_test_util.h"
+#include "chrome/browser/extensions/extension_with_management_policy_apitest.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/browser/profiles/profile_destroyer.h"
 #include "components/content_settings/core/common/content_settings.h"
@@ -27,6 +30,8 @@
 #include "extensions/test/extension_test_message_listener.h"
 #include "extensions/test/test_extension_dir.h"
 #include "mojo/public/cpp/bindings/remote.h"
+#include "net/cookies/canonical_cookie.h"
+#include "net/cookies/cookie_options.h"
 #include "net/dns/mock_host_resolver.h"
 #include "net/test/embedded_test_server/default_handlers.h"
 #include "services/network/public/mojom/cookie_manager.mojom.h"
@@ -515,4 +520,68 @@
   )");
 }
 
+using CookiesPolicyApiTest = ExtensionApiTestWithManagementPolicy;
+
+// Tests that cookies.onChanged is not dispatched for hosts blocked by the
+// ExtensionSettings `runtime_blocked_hosts` policy. The cookies API functions
+// already reject such hosts. Regression test for https://crbug.com/517094892.
+IN_PROC_BROWSER_TEST_F(CookiesPolicyApiTest,
+                       OnChangedRespectsPolicyBlockedHosts) {
+  {
+    ExtensionManagementPolicyUpdater pref(&policy_provider_);
+    pref.AddPolicyBlockedHost("*", "*://blocked.example");
+  }
+
+  static constexpr char kManifest[] = R"({
+    "name": "Cookies policy test",
+    "version": "1.0",
+    "manifest_version": 3,
+    "permissions": ["cookies"],
+    "host_permissions": ["<all_urls>"],
+    "background": {"service_worker": "background.js"}
+  })";
+  static constexpr char kBackgroundJs[] = R"(
+    chrome.cookies.onChanged.addListener((changeInfo) => {
+      chrome.test.sendMessage('changed:' + changeInfo.cookie.domain);
+    });
+    chrome.test.sendMessage('ready');
+  )";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackgroundJs);
+
+  ExtensionTestMessageListener ready_listener("ready");
+  ASSERT_TRUE(LoadExtension(test_dir.UnpackedPath()));
+  ASSERT_TRUE(ready_listener.WaitUntilSatisfied());
+
+  ExtensionTestMessageListener blocked_listener("changed:blocked.example");
+  ExtensionTestMessageListener allowed_listener("changed:allowed.example");
+
+  auto set_cookie = [this](const GURL& url) {
+    std::unique_ptr<net::CanonicalCookie> cookie = net::CanonicalCookie::Create(
+        url, "sid=secret; Path=/; HttpOnly", base::Time::Now(),
+        /*server_time=*/std::nullopt, /*cookie_partition_key=*/std::nullopt,
+        net::CookieSourceType::kOther, /*status=*/nullptr);
+    ASSERT_TRUE(cookie);
+    base::test::TestFuture<net::CookieAccessResult> future;
+    profile()
+        ->GetDefaultStoragePartition()
+        ->GetCookieManagerForBrowserProcess()
+        ->SetCanonicalCookie(*cookie, url,
+                             net::CookieOptions::MakeAllInclusive(),
+                             future.GetCallback());
+    EXPECT_TRUE(future.Take().status.IsInclude());
+  };
+
+  // Cookie changes reach the extension in the order they happen, so once the
+  // event for the allowed host has arrived, the event for the blocked host
+  // would have arrived too if it had leaked.
+  set_cookie(GURL("http://blocked.example/"));
+  set_cookie(GURL("http://allowed.example/"));
+
+  EXPECT_TRUE(allowed_listener.WaitUntilSatisfied());
+  EXPECT_FALSE(blocked_listener.was_satisfied());
+}
+
 }  // namespace extensions
diff --git a/extensions/browser/event_router_unittest.cc b/extensions/browser/event_router_unittest.cc
index 0bdd77e..b1fe5d0 100644
--- a/extensions/browser/event_router_unittest.cc
+++ b/extensions/browser/event_router_unittest.cc
@@ -40,6 +40,9 @@
 #include "extensions/common/features/simple_feature.h"
 #include "extensions/common/mojom/context_type.mojom.h"
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
+#include "extensions/common/permissions/permissions_data.h"
+#include "extensions/common/url_pattern.h"
+#include "extensions/common/url_pattern_set.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/service_worker/service_worker_database.mojom-blink-forward.h"
 
@@ -1813,4 +1816,41 @@
             dispatched_event->user_gesture);
 }
 
+// Tests that an event scoped to a URL isn't dispatched for hosts blocked by
+// enterprise policy, even if the extension has host permissions for them.
+// Regression test for https://crbug.com/517094892.
+TEST_F(EventRouterDispatchTest, EventURLRespectsPolicyBlockedHosts) {
+  const ExtensionId ext1 = "ext1";
+  RegisterTestApiFeature(StaticFeatureData(kTestEventFeatureData));
+
+  scoped_refptr<const Extension> extension =
+      ExtensionBuilder("test extension")
+          .SetID(ext1)
+          .AddHostPermission("<all_urls>")
+          .Build();
+  ExtensionRegistry::Get(browser_context())->AddEnabled(extension);
+
+  URLPatternSet policy_blocked_hosts;
+  policy_blocked_hosts.AddPattern(
+      URLPattern(URLPattern::SCHEME_ALL, "*://blocked.example/*"));
+  extension->permissions_data()->SetPolicyHostRestrictions(
+      policy_blocked_hosts, /*policy_allowed_hosts=*/URLPatternSet());
+
+  TestEventRouterObserver observer(event_router());
+  event_router()->AddEventListenerForTesting(kTestEventName, process(), ext1);
+
+  auto create_event = [&](const char* url) {
+    auto event = std::make_unique<Event>(events::FOR_TEST, kTestEventName,
+                                         base::ListValue(), browser_context());
+    event->event_url = GURL(url);
+    return event;
+  };
+
+  event_router()->BroadcastEvent(create_event("http://blocked.example/"));
+  EXPECT_EQ(0u, observer.dispatched_events().size());
+
+  event_router()->BroadcastEvent(create_event("http://allowed.example/"));
+  EXPECT_EQ(1u, observer.dispatched_events().size());
+}
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of enterprise runtime_blocked_hosts and user block lists via cookies.onChanged

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: The chrome.cookies.onChanged event can potentially allow an extension with cookies and host permissions to passively monitor cookie modifications on enterprise-blocked or user-blocked origins. This occurs because the event dispatch system relies on a bare pattern-matching check in EventDispatchHelper::CheckPermissions that does not consult policy-aware or user-restricted filters. This bypass could potentially allow a restricted extension to leak sensitive session tokens.

Affected files:

  • extensions/browser/events/event_dispatch_helper.cc

Estimated timestamp from git blame: 2017-04-17

Root Cause

In extensions/browser/events/event_dispatch_helper.cc, the helper function CheckPermissions acts as the primary host-permission gate during event dispatch. For events associated with a URL (such as cookie changes), it verifies access by calling PermissionSet::HasEffectiveAccessToURL:

if (!event.event_url.is_empty() &&
    event.event_url.GetHost() != extension->id() &&
    !extension->permissions_data()
         ->active_permissions()
         .HasEffectiveAccessToURL(event.event_url)) {
  return false;
}

However, PermissionSet::HasEffectiveAccessToURL (defined in extensions/common/permissions/permission_set.cc) only checks whether the URL is matched by the extension’s configured host patterns (e.g. <all_urls>):

bool PermissionSet::HasEffectiveAccessToURL(const GURL& url) const {
  return effective_hosts().MatchesURL(url);
}

This check completely bypasses policy-aware filters such as IsPolicyBlockedHostUnsafe() (enterprise runtime_blocked_hosts policy) and IsUrlBlockedByUser() (user-level block lists), which are only consulted in helper classes like PermissionsData during API method execution. Because of this, while imperative methods like chrome.cookies.get or chrome.cookies.set correctly enforce host-blocking policies, passive event listeners do not.

Potential Attack Scenario

An attacker could potentially exploit this behavior using the following steps:

  1. The attacker publishes a browser extension requesting the "cookies" permission and broad host permissions (such as "<all_urls>").
  2. A user installs the extension in a profile where an enterprise administrator has set the ExtensionSettings policy to block a sensitive target origin (e.g., *://victim.example) via runtime_blocked_hosts.
  3. The extension registers a listener for chrome.cookies.onChanged in its background context:
    chrome.cookies.onChanged.addListener((changeInfo) => {
      console.log("Leaked cookie modification:", changeInfo);
    });
    
  4. A cookie change occurs on https://victim.example (e.g., during login or session updates).
  5. CookiesEventRouter::OnCookieChange (in chrome/browser/extensions/api/cookies/cookies_api.cc) serializes the new cookie value and constructs an event with event_url set to the cookie’s origin.
  6. The browser process routes the event to EventDispatchHelper. Since CheckPermissions only evaluates pattern-matching constraints on active permissions and ignores policy block lists, the check passes.
  7. The event is successfully delivered to the extension’s listener, leaking sensitive cookie data (including potential HttpOnly session tokens) from a restricted host.

Note: These steps are suggested and potential; our analysis has been conducted statically as our tooling agent does not have the ability to run code.

Suggested Fix

To remediate this potential bypass, CheckPermissions in extensions/browser/events/event_dispatch_helper.cc should check if the target event_url is blocked by enterprise policy or user-configured block lists. For example:

if (!event.event_url.is_empty() &&
    event.event_url.GetHost() != extension->id()) {
  if (!extension->permissions_data()
           ->active_permissions()
           .HasEffectiveAccessToURL(event.event_url)) {
    return false;
  }
  if (extension->permissions_data()->IsPolicyBlockedHost(event.event_url) ||
      extension->permissions_data()->IsUrlBlockedByUser(event.event_url)) {
    return false;
  }
}

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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