CVE-2026-87591
Overview
Background
- `chrome.cookies` API
- an extension API that lets an extension read, create, and remove cookies for domains it has host access to, scoped to a browser profile rather than a specific tab.
- `PermissionsData::HasHostPermission`
- a check that returns whether a URL is covered by the extension’s explicitly-granted manifest host permissions and is not blocked by enterprise policy, but ignores user-level site controls and runtime-withheld permissions.
- `PermissionsData::GetPageAccess`
- a superset access check that returns a
PageAccessenum (kAllowed,kWithheld,kDenied) after also validating user host restrictions, withheld/runtime host permissions, enterprise policy, and restricted URLs. - Withheld / user host restrictions
- the runtime host-access model (gated by
kExtensionsMenuAccessControl) in which a user can turn an extension off for a site or withhold a host permission the manifest requested, without the extension losing the permission in its manifest.
Root Cause Analysis
The chrome.cookies host-access gate was implemented with extension->permissions_data()->HasHostPermission(url) in both CheckHostPermissions (cookies_api.cc) and the per-cookie filter in AppendMatchingCookiesFromCookieListToVector (cookies_helpers.cc). HasHostPermission only reflects the static manifest grant plus enterprise blocks, so it returned true for a domain even when the user had explicitly turned the extension off for that site or had withheld the corresponding host permission at runtime. The violated invariant is that effective host access — not merely declared host access — must gate cookie reads and writes, so an extension with <all_urls> in its manifest could still enumerate, create, and delete cookies for sites the user thought were off-limits.
The fix replaces both calls with GetPageAccess(url, extension_misc::kUnknownTabId, ...) != PermissionsData::PageAccess::kAllowed, which fails closed unless access is fully kAllowed, thereby also honoring user site restrictions, withheld permissions, and restricted URLs. kUnknownTabId is passed deliberately because cookie operations are profile-scoped and must not be satisfied by any tab-specific activeTab-style grant.
HasHostPermission) instead of its effective runtime access; swapping to GetPageAccess(...) == kAllowed makes the check honor user withholding and site blocks and fail closed.Attack Path
- Install an extension with broad host permissions
a malicious or over-privileged extension declares
<all_urls>(or a target host) in its manifest, satisfying the oldHasHostPermissioncheck permanently. - User restricts the site the user turns the extension off for a sensitive domain or withholds its host permission via the runtime host-access controls, expecting the extension to lose access to that domain.
- Extension calls `chrome.cookies`
the extension invokes
cookies.getAll/get/set/removefor the restricted domain; the pre-patchCheckHostPermissionsandAppendMatchingCookiesFromCookieListToVectorstill seeHasHostPermission == trueand allow the operation. - Cookies leak or are modified cookies for the blocked domain are returned to (or written/deleted by) the extension despite the user’s restriction, defeating the user’s site-access decision.
- Downstream session compromise (inferred) reading authentication or session cookies for a restricted site could enable session hijacking, but the patch itself demonstrates only the unauthorized cookie access, not any further escalation.
Impact Assessment
kExtensionsMenuAccessControl) is in effect. This is an incorrect-authorization bug in Extensions as the advisory states; any escalation to full session hijacking is (inferred) and not shown by the patch, and the advisory does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/cookies/cookies_api.cc |
modified | |
ifchrome/browser/extensions/api/cookies/cookies_unittest.cc |
modified | |
TEST_Fchrome/browser/extensions/api/cookies/cookies_unittest.cc |
modified |
Files Changed
chrome/browser/extensions/api/cookies/cookies_helpers.ccchrome/browser/extensions/api/cookies/cookies_unittest.ccchrome/browser/extensions/api/cookies/cookies_api.cc
Audit Directions
- `HasHostPermission` as an authorization gateaudit every API that calls
PermissionsData::HasHostPermissionto decide access; it ignores user withholding and site blocks, so profile-scoped or content-affecting APIs should generally useGetPageAccess(...) == kAllowedinstead. - Profile-scoped APIs and tab contextreview other non-tab-bound extension APIs (storage, history, browsing data, downloads) to confirm they pass
kUnknownTabIdand do not accept tab-specific grants to authorize profile-wide operations. - Declared-vs-effective permission checkslook for any check that consults manifest/optional permissions without also consulting runtime withheld and user-restricted host sets, since these diverge whenever
kExtensionsMenuAccessControlrestrictions are active.
Patch
From 6813284e59b397be04f5aadd5f4b9ffe87edddff Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Fri, 14 Aug 2026 18:17:01 -0700 Subject: [PATCH] [Extensions] Enforce user site restrictions in chrome.cookies API Previously, the chrome.cookies API verified host access permissions using PermissionsData::HasHostPermission(url) which checks explicit manifest host permissions and enterprise policy host blocks, but does not check user host restrictions or withheld permissions. So when a user explicitly turns off extensions for a site or withholds host permissions, an extension with host permissions could continue using chrome.cookies methods to read, create, and remove cookies for that domain. This CL replaces the HasHostPermission check with GetPageAccess which is a superset of what HasHostPermission checks that also validates user site restrictions, withheld permissions, enterprise policy, and restricted URLs. Fixed: 543938457 Change-Id: Ic46119d4da0bb32b84c3c3e90eec696c110a2e16 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8259390 Reviewed-by: Solomon Kinard <[email protected]> Commit-Queue: Eva Su <[email protected]> Cr-Commit-Position: refs/heads/main@{#1680073} --- diff --git a/chrome/browser/extensions/api/cookies/cookies_api.cc b/chrome/browser/extensions/api/cookies/cookies_api.cc index b605d852..46d1698f 100644 --- a/chrome/browser/extensions/api/cookies/cookies_api.cc +++ b/chrome/browser/extensions/api/cookies/cookies_api.cc @@ -28,6 +28,7 @@ #include "extensions/browser/event_router.h" #include "extensions/browser/extension_api_frame_id_map.h" #include "extensions/browser/extensions_browser_client.h" +#include "extensions/common/constants.h" #include "extensions/common/error_utils.h" #include "extensions/common/extension.h" #include "extensions/common/permissions/permissions_data.h" @@ -74,9 +75,16 @@ bool CheckHostPermissions(const Extension* extension, const GURL& url, std::string* error) { - if (!extension->permissions_data()->HasHostPermission(url)) { - *error = - ErrorUtils::FormatErrorMessage(kNoHostPermissionsError, url.spec()); + // Cookie operations are profile-scoped and not tied to a specific tab + // context so we pass kUnknownTabId to check page access without considering + // tab-specific grants. + if (extension->permissions_data()->GetPageAccess( + url, extension_misc::kUnknownTabId, /*error=*/nullptr) != + PermissionsData::PageAccess::kAllowed) { + if (error) { + *error = + ErrorUtils::FormatErrorMessage(kNoHostPermissionsError, url.spec()); + } return false; } return true; diff --git a/chrome/browser/extensions/api/cookies/cookies_helpers.cc b/chrome/browser/extensions/api/cookies/cookies_helpers.cc index 911552d..fa5319f 100644 --- a/chrome/browser/extensions/api/cookies/cookies_helpers.cc +++ b/chrome/browser/extensions/api/cookies/cookies_helpers.cc @@ -22,6 +22,7 @@ #include "chrome/browser/profiles/profile.h" #include "chrome/common/extensions/api/cookies.h" #include "content/public/browser/web_contents.h" +#include "extensions/common/constants.h" #include "extensions/common/extension.h" #include "extensions/common/permissions/permissions_data.h" #include "mojo/public/cpp/bindings/callback_helpers.h" @@ -47,10 +48,13 @@ const Extension* extension, std::vector<Cookie>* match_vector, const net::CookiePartitionKeyCollection& cookie_partition_key_collection) { - // Ignore any cookie whose domain doesn't match the extension's - // host permissions. + // Ignore any cookie whose domain doesn't match the extension's page access + // controls. Pass kUnknownTabId because cookie operations are profile-wide + // rather than tab-bound. GURL cookie_domain_url = cookies_helpers::GetURLFromCanonicalCookie(cookie); - if (!extension->permissions_data()->HasHostPermission(cookie_domain_url)) { + if (extension->permissions_data()->GetPageAccess( + cookie_domain_url, extension_misc::kUnknownTabId, nullptr) != + PermissionsData::PageAccess::kAllowed) { return; } // Filter the cookie using the match filter. diff --git a/chrome/browser/extensions/api/cookies/cookies_unittest.cc b/chrome/browser/extensions/api/cookies/cookies_unittest.cc index 2f8a254..526d102 100644 --- a/chrome/browser/extensions/api/cookies/cookies_unittest.cc +++ b/chrome/browser/extensions/api/cookies/cookies_unittest.cc @@ -15,6 +15,7 @@ #include <vector> #include "base/test/gtest_util.h" +#include "base/test/scoped_feature_list.h" #include "base/values.h" #include "chrome/browser/extensions/api/cookies/cookies_helpers.h" #include "chrome/common/extensions/api/cookies.h" @@ -22,6 +23,9 @@ #include "chrome/test/base/testing_profile.h" #include "content/public/test/browser_task_environment.h" #include "extensions/buildflags/buildflags.h" +#include "extensions/common/extension_builder.h" +#include "extensions/common/extension_features.h" +#include "extensions/common/permissions/permissions_data.h" #include "net/cookies/canonical_cookie.h" #include "net/cookies/cookie_constants.h" #include "testing/gtest/include/gtest/gtest.h" @@ -305,4 +309,88 @@ EXPECT_CHECK_DEATH(cookies_helpers::CreateCookie(*opaque_cookie, "0")); } +namespace { + +std::vector<Cookie> GetMatchingCookiesForExtension( + const Extension* extension, + const std::string& domain = "example.com") { + auto cookie = net::CanonicalCookie::CreateUnsafeCookieForTesting( + "ABC", "DEF", domain, "/", base::Time(), base::Time(), base::Time(), + base::Time(), false, false, net::CookieSameSite::NO_RESTRICTION, + net::COOKIE_PRIORITY_DEFAULT, net::CookieSourceType::kOther); + if (!cookie) { + return {}; + } + + base::DictValue dict; + dict.Set("storeId", "0"); + auto details = GetAll::Params::Details::FromValue(dict); + if (!details) { + return {}; + } + + std::vector<Cookie> match_vector; + cookies_helpers::AppendMatchingCookiesFromCookieListToVector( + {*cookie}, &details.value(), extension, &match_vector, + net::CookiePartitionKeyCollection()); + return match_vector; +} + +} // namespace + +// Ensures cookies from domains explicitly blocked by the user are excluded from +// matches. +TEST_F(ExtensionCookiesTest, AppendMatchingCookiesWithUserBlockedSite) { + base::test::ScopedFeatureList feature_list( + extensions_features::kExtensionsMenuAccessControl); + + scoped_refptr<const Extension> extension = + ExtensionBuilder("Test Extension") + .AddHostPermission("<all_urls>") + .Build(); + + constexpr int kContextId = 1; + extension->permissions_data()->SetContextId(kContextId); + + // Mark example.com as user-blocked. + URLPatternSet user_blocked_hosts; + URLPattern pattern(URLPattern::SCHEME_ALL, "*://*.example.com/*"); + user_blocked_hosts.AddPattern(pattern); + PermissionsData::SetUserHostRestrictions(kContextId, + std::move(user_blocked_hosts), + /*user_allowed_hosts=*/{}); + + // Since example.com is user-blocked, the cookie must not be matched. + EXPECT_TRUE(GetMatchingCookiesForExtension(extension.get()).empty()); +} + +// Ensures cookies from domains with withheld host permissions are excluded from +// matches. +TEST_F(ExtensionCookiesTest, AppendMatchingCookiesWithWithheldPermissions) { + base::test::ScopedFeatureList feature_list( + extensions_features::kExtensionsMenuAccessControl); + + base::ListValue host_permissions; + host_permissions.Append("*://*.example.com/*"); + + scoped_refptr<const Extension> extension = + ExtensionBuilder("Test Extension") + .SetManifestKey("host_permissions", std::move(host_permissions)) + .Build(); + + // Withhold the host permission from the extension. + URLPatternSet withheld_hosts; + withheld_hosts.AddPattern( + URLPattern(URLPattern::SCHEME_ALL, "*://*.example.com/*")); + + extension->permissions_data()->SetPermissions( + std::make_unique<PermissionSet>(), + std::make_unique<PermissionSet>(APIPermissionSet(), + ManifestPermissionSet(), + withheld_hosts.Clone(), URLPatternSet())); + + // Since the permission is withheld, the cookie must not be matched. + EXPECT_TRUE(GetMatchingCookiesForExtension(extension.get()).empty()); +} + } // namespace extensions
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/cookies/cookies_unittest.cc b/chrome/browser/extensions/api/cookies/cookies_unittest.cc
index 2f8a254..526d102 100644
--- a/chrome/browser/extensions/api/cookies/cookies_unittest.cc
+++ b/chrome/browser/extensions/api/cookies/cookies_unittest.cc
@@ -15,6 +15,7 @@
#include <vector>
#include "base/test/gtest_util.h"
+#include "base/test/scoped_feature_list.h"
#include "base/values.h"
#include "chrome/browser/extensions/api/cookies/cookies_helpers.h"
#include "chrome/common/extensions/api/cookies.h"
@@ -22,6 +23,9 @@
#include "chrome/test/base/testing_profile.h"
#include "content/public/test/browser_task_environment.h"
#include "extensions/buildflags/buildflags.h"
+#include "extensions/common/extension_builder.h"
+#include "extensions/common/extension_features.h"
+#include "extensions/common/permissions/permissions_data.h"
#include "net/cookies/canonical_cookie.h"
#include "net/cookies/cookie_constants.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -305,4 +309,88 @@
EXPECT_CHECK_DEATH(cookies_helpers::CreateCookie(*opaque_cookie, "0"));
}
+namespace {
+
+std::vector<Cookie> GetMatchingCookiesForExtension(
+ const Extension* extension,
+ const std::string& domain = "example.com") {
+ auto cookie = net::CanonicalCookie::CreateUnsafeCookieForTesting(
+ "ABC", "DEF", domain, "/", base::Time(), base::Time(), base::Time(),
+ base::Time(), false, false, net::CookieSameSite::NO_RESTRICTION,
+ net::COOKIE_PRIORITY_DEFAULT, net::CookieSourceType::kOther);
+ if (!cookie) {
+ return {};
+ }
+
+ base::DictValue dict;
+ dict.Set("storeId", "0");
+ auto details = GetAll::Params::Details::FromValue(dict);
+ if (!details) {
+ return {};
+ }
+
+ std::vector<Cookie> match_vector;
+ cookies_helpers::AppendMatchingCookiesFromCookieListToVector(
+ {*cookie}, &details.value(), extension, &match_vector,
+ net::CookiePartitionKeyCollection());
+ return match_vector;
+}
+
+} // namespace
+
+// Ensures cookies from domains explicitly blocked by the user are excluded from
+// matches.
+TEST_F(ExtensionCookiesTest, AppendMatchingCookiesWithUserBlockedSite) {
+ base::test::ScopedFeatureList feature_list(
+ extensions_features::kExtensionsMenuAccessControl);
+
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test Extension")
+ .AddHostPermission("<all_urls>")
+ .Build();
+
+ constexpr int kContextId = 1;
+ extension->permissions_data()->SetContextId(kContextId);
+
+ // Mark example.com as user-blocked.
+ URLPatternSet user_blocked_hosts;
+ URLPattern pattern(URLPattern::SCHEME_ALL, "*://*.example.com/*");
+ user_blocked_hosts.AddPattern(pattern);
+ PermissionsData::SetUserHostRestrictions(kContextId,
+ std::move(user_blocked_hosts),
+ /*user_allowed_hosts=*/{});
+
+ // Since example.com is user-blocked, the cookie must not be matched.
+ EXPECT_TRUE(GetMatchingCookiesForExtension(extension.get()).empty());
+}
+
+// Ensures cookies from domains with withheld host permissions are excluded from
+// matches.
+TEST_F(ExtensionCookiesTest, AppendMatchingCookiesWithWithheldPermissions) {
+ base::test::ScopedFeatureList feature_list(
+ extensions_features::kExtensionsMenuAccessControl);
+
+ base::ListValue host_permissions;
+ host_permissions.Append("*://*.example.com/*");
+
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test Extension")
+ .SetManifestKey("host_permissions", std::move(host_permissions))
+ .Build();
+
+ // Withhold the host permission from the extension.
+ URLPatternSet withheld_hosts;
+ withheld_hosts.AddPattern(
+ URLPattern(URLPattern::SCHEME_ALL, "*://*.example.com/*"));
+
+ extension->permissions_data()->SetPermissions(
+ std::make_unique<PermissionSet>(),
+ std::make_unique<PermissionSet>(APIPermissionSet(),
+ ManifestPermissionSet(),
+ withheld_hosts.Clone(), URLPatternSet()));
+
+ // Since the permission is withheld, the cookie must not be matched.
+ EXPECT_TRUE(GetMatchingCookiesForExtension(extension.get()).empty());
+}
+
} // namespace extensions
Original Bug Report
chrome.cookies ignores user-blocked sites and exposes HttpOnly session cookies
Report description
chrome.cookies ignores user-blocked sites and exposes HttpOnly session cookies
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
Chrome’s global Allow extensions on this site control blocks script
injection on a user-restricted origin, but the same restriction is not applied
to the chrome.cookies API. An extension with the documented cookies and
host permissions can continue reading, monitoring, creating, and removing
cookies for an origin after the user explicitly blocks all extensions there.
The attached PoC demonstrates the discrepancy with synthetic data. On the same
restricted origin, chrome.scripting.executeScript() returns Error: Blocked,
while chrome.cookies.get() returns the complete value of an authenticated
HttpOnly session cookie. chrome.cookies.onChanged also receives a second
HttpOnly cookie created after the restriction is active, and set() plus
remove() both succeed. Finally, the PoC sends the stolen synthetic credential
to a separate local origin, which replays it against /private and receives the
authenticated account response.
I confirmed this without Chrome feature or security flags in Chrome for Testing
Beta 152.0.7977.30 and Dev 153.0.7979.3 on macOS arm64.
Steps to reproduce
-
Run the attached server:
python3 server.py -
Open
chrome://extensions, enable Developer mode, and load the attached extension files as an unpacked extension. -
Visit
http://127.0.0.1:18774/set-cookie. The response creates:victim_session=SYNTHETIC_HTTPONLY_SESSION_4E8D1C93; HttpOnly -
From Chrome’s extensions menu, turn off Allow extensions on this site for
http://127.0.0.1:18774, then reload. Chrome now states that extensions are not allowed on this site. -
Visit
http://127.0.0.1:18774/passive-eventafter the block is active. The response creates a secondHttpOnlycookie namedpassive_secret. -
Visit
http://127.0.0.1:18774/private, open the PoC extension, and click Run probe. -
The server started in step 1 also runs a synthetic attacker collector at
http://127.0.0.1:18776. Observe the following result fields:{ "affected": true, "expectedControl": { "scriptInjectionBlocked": true, "result": {"ok": false, "error": "Error: Blocked"} }, "confidentiality": { "httpOnlyCookieRead": { "ok": true, "value": { "httpOnly": true, "name": "victim_session", "value": "SYNTHETIC_HTTPONLY_SESSION_4E8D1C93" } }, "sessionHijackConfirmed": true, "exfiltrationAndReplay": { "ok": true, "value": {"replayStatus": 200, "authenticated": true} } }, "passiveEventAfterBlock": { "cookie": { "httpOnly": true, "name": "passive_secret", "value": "PASSIVE_HTTPONLY_EVENT_91B6D2E4" } } } -
The same output shows that the extension creates, reads, and removes
proof_mutation=EXTENSION_WROTE_BLOCKED_SITE_7F4A2B10on the blocked origin.
Expected behavior
Once the user blocks extensions on an origin, cookie API calls for that origin
should be rejected, cookies from that origin should be omitted from getAll(),
and cookie change events for that origin should not be delivered.
Actual behavior
Only page/script access is denied. All tested cookie API read, event, write, and delete paths continue to expose or modify the blocked origin’s cookie store.
Root cause
CheckHostPermissions() in cookies_api.cc calls
PermissionsData::HasHostPermission(). getAll() applies the same helper in
cookies_helpers.cc, and event delivery checks the extension’s active manifest
permissions. These checks do not consult the global user-restricted-site list.
PermissionsData::HasHostPermission() checks explicit manifest access and
enterprise policy restrictions only. In contrast, GetPageAccess() reaches
CanRunOnPage(), which calls IsUrlBlockedByUser() and returns
PageAccess::kDenied. This explains why the script-injection control is blocked
while the cookie paths remain allowed.
- cookies_api.cc host check
- cookies_helpers.cc getAll filter
- event_dispatch_helper.cc URL permission check
- permissions_data.cc permission paths
- Chrome host-permission controls explicitly include cookies
Suggested fix
Require PageAccess::kAllowed, or at minimum reject
IsUrlBlockedByUser(url), for every chrome.cookies read/write operation and
for URL-scoped cookie event dispatch. Add regression tests covering get, both
getAll paths, set, remove, and onChanged with a globally
user-restricted origin.
Attachments
server.pymanifest.jsonworker.jspopup.htmlpopup.jsevidence/cft-beta-152/results.jsonevidence/cft-beta-152/popup-result.pngevidence/cft-dev-153/results.jsonevidence/cft-dev-153/popup-result.png
Impact analysis
Security impact
A malicious or compromised extension that requested cookies and matching host
permissions can bypass the user’s explicit attempt to protect a sensitive site.
It can extract HttpOnly authentication credentials, exfiltrate and replay
them to impersonate the user, monitor newly issued or rotated credentials in
the background, and modify or delete site cookies even though Chrome tells the
user that extensions are not allowed on that site. The attached local replay
confirms the stolen value is sufficient to access the synthetic private account.
The report does not claim that the extension lacks declared permissions. The
security boundary being bypassed is the user’s later, explicit host-access
restriction, which Chrome documents as applying to APIs that require host
permissions, including cookies.
The cause
What version of Chrome have you found the security issue in?
Beta 152.0.7977.30 and Dev 153.0.7979.3
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Other
- http://127.0.0.1:18774
- http://127.0.0.1:18774/passive-event
- http://127.0.0.1:18774/private
- http://127.0.0.1:18774/set-cookie
- http://127.0.0.1:18776
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/+/refs/heads/main/chrome/browser/extensions/api/cookies/cookies_api.cc#74
- https://chromium.googlesource.com/chromium/src/+/refs/heads/main/chrome/browser/extensions/api/cookies/cookies_helpers.cc#44
- https://chromium.googlesource.com/chromium/src/+/refs/heads/main/extensions/browser/events/event_dispatch_helper.cc#66
- https://chromium.googlesource.com/chromium/src/+/refs/heads/main/extensions/common/permissions/permissions_data.cc#392
- https://developer.chrome.com/docs/extensions/mv2/runtime-host-permissions