CVE-2026-113262
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fextensions/browser/event_router_unittest.cc |
modified |
Files Changed
extensions/browser/event_router_unittest.ccchrome/browser/extensions/api/cookies/cookies_apitest.ccextensions/browser/events/event_dispatch_helper.cc
Patch
From 131714ab59d24d7206f34fef918d94363df696b2 Mon Sep 17 00:00:00 2001 From: Andrea Orru <[email protected]> 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 <[email protected]> Commit-Queue: Andrea Orru <[email protected]> Reviewed-by: Devlin Cronin <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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:
- The attacker publishes a browser extension requesting the
"cookies"permission and broad host permissions (such as"<all_urls>"). - A user installs the extension in a profile where an enterprise administrator has set the
ExtensionSettingspolicy to block a sensitive target origin (e.g.,*://victim.example) viaruntime_blocked_hosts. - The extension registers a listener for
chrome.cookies.onChangedin its background context:chrome.cookies.onChanged.addListener((changeInfo) => { console.log("Leaked cookie modification:", changeInfo); }); - A cookie change occurs on
https://victim.example(e.g., during login or session updates). CookiesEventRouter::OnCookieChange(inchrome/browser/extensions/api/cookies/cookies_api.cc) serializes the new cookie value and constructs an event withevent_urlset to the cookie’s origin.- The browser process routes the event to
EventDispatchHelper. SinceCheckPermissionsonly evaluates pattern-matching constraints on active permissions and ignores policy block lists, the check passes. - The event is successfully delivered to the extension’s listener, leaking sensitive cookie data (including potential
HttpOnlysession 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.