Low watch CVSS 6.5 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in DevTools
DescriptionPolicy bypass in DevTools
ComponentDevTools
Bug ClassPolicy Bypass
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker479673903
Fix commit9a6fcf26c50b (chromium/src) +230/-88
CWECWE-602
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedPovcfe of Tencent Security Xuanwu Lab
Reported2026-01-29 (per vendor advisory)
Fix landed2026-02-26 (public git)
Disclosed2026-04-07
Patch gap40 days — fix public in source before the advisory

Background

`DevToolsAgentHostClient::MayAttachToURL`
the per-client policy hook a DevTools/CDP client (for example an extension using the debugger API, or a client restricted by browser policy) uses to say which URLs it may inspect; the regression test drives it with SetNotAttachableHosts({"b.test"}).
CDP cookie commands
Network.getCookies/getAllCookies, Network.setCookie/setCookies, Network.deleteCookies, Network.clearBrowserCookies and the Storage.*Cookies equivalents, all implemented in network_handler.cc and storage_handler.cc on top of the browser-process CookieManager.
Read-side filtering
before this patch, cookie reads were already filtered by calling MayAttachToURL on https://<domain> and http://<domain> for each cookie; that inline logic is what the patch extracts into NetworkHandler::CanAccessCookie.
`CookieDeletionFilter::New()`
an empty deletion filter, which deletes every cookie in the storage partition in one call.

Root Cause Analysis

The DevTools cookie handlers enforced the client’s attach policy only on the read path: the removed lines in the cookie-retrieval code of network_handler.cc and storage_handler.cc filtered results with client_->MayAttachToURL(...), but the write and delete paths had no equivalent check. Network.setCookie passed the cookie from MakeCookieFromProtocolValues directly to the cookie manager, the static NetworkHandler::SetCookies (shared by Network.setCookies and Storage.setCookies) did the same for each entry, DeleteFilteredCookies deleted every cookie matching name, domain and path, and both Network.clearBrowserCookies and Storage.clearCookies called DeleteCookies with an empty CookieDeletionFilter, wiping every cookie in the partition. A client that policy barred from a host could therefore still create, overwrite or delete that host’s cookies, which breaks the rule that a restricted client can only affect the origins it is allowed to attach to.

The fix adds a single predicate, NetworkHandler::CanAccessCookie, which requires MayAttachToURL to pass for both the https and http forms of cookie.DomainWithoutDot().

The patch applies it to every mutation: setCookie returns Response::ServerError("Permission denied") before the cookie is set, SetCookies fails the whole batch (callback.Run(false)) on the first inaccessible cookie before anything is written, and DeleteFilteredCookies removes inaccessible cookies from the list with std::erase_if before issuing any DeleteCanonicalCookie. Clearing goes through the new NetworkHandler::ClearCookies, which runs GetAllCookies and then the same filtered per-cookie deletion with no name, domain or path filter (filter_by_partition_key=false), replacing the blanket DeleteCookies.

Key insight
The access-control check lived only on the cookie read path, so write, delete and clear commands ignored the client’s URL policy. The fix moves the check into CanAccessCookie and runs it on every cookie before any mutation reaches the CookieManager.

Attack Path

  1. Restricted CDP client attaches a DevTools client whose MayAttachToURL rejects some host (b.test in the regression test) attaches to a target it is allowed to debug.
  2. Write to a forbidden host it sends Network.setCookie or Network.setCookies (or Storage.setCookies) with domain set to the forbidden host. Before the patch, the cookie was built by MakeCookieFromProtocolValues and stored without any policy check.
  3. Delete or clear forbidden cookies it sends Network.deleteCookies naming the forbidden domain, or Network.clearBrowserCookies/Storage.clearCookies. Before the patch, these deleted matching cookies, or all cookies through an empty CookieDeletionFilter, regardless of host.
  4. Effect on the protected site the forbidden site’s session state is planted, overwritten or erased (for example session fixation or forced logout, (inferred)), even though the client could not read those cookies. The test shows that after the fix b.test’s foo=bar survives clearBrowserCookies while the a.test cookie is removed.

Impact Assessment

A DevTools protocol client that is restricted by policy can modify or delete cookies for hosts it is not allowed to attach to. This is an integrity bypass in the browser-process DevTools handlers, not a way to read those cookies, because reads were already filtered. The precondition is an attached CDP client with a restricted URL policy (such as an extension debugger client), and the vendor rates it low severity. The diff contains nothing about in-the-wild exploitation, and any consequence beyond cookie integrity, such as account takeover through session fixation, is (inferred).

Changed Functions

FunctionChangeNotes
for
content/browser/devtools/protocol/network_handler.cc
modified
if
content/browser/devtools/protocol/network_handler.cc
modified
erase_if
content/browser/devtools/protocol/network_handler.cc
modified
for
content/browser/devtools/protocol/storage_handler.cc
modified
if
content/browser/devtools/protocol/storage_handler.cc
modified

Files Changed

  • content/browser/devtools/protocol/devtools_protocol_browsertest.cc
  • content/browser/devtools/protocol/network_handler.cc
  • content/browser/devtools/protocol/network_handler.h
  • content/browser/devtools/protocol/storage_handler.cc
  • third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt

Audit Directions

  • Read-only enforcement of per-client URL policy
    for every CDP domain that filters reads with MayAttachToURL, check whether the matching set, delete, clear or override commands (for example Storage.clearDataForOrigin, Storage.setInterestGroup*, DOMStorage.*, IndexedDB.*, CacheStorage.*) apply the same check.
  • Blanket filters versus per-item checks
    look for DeleteCookies/ClearData-style calls with empty filters that act on a whole partition for a restricted client. Note that the new GetAllCookies → per-cookie delete flow is not atomic, so check that callers do not depend on all-or-nothing clearing.
  • Domain-to-URL mapping in `CanAccessCookie`
    the check uses cookie.DomainWithoutDot(). Review how host-only cookies, IP-literal domains, partitioned cookies whose top-level site differs from the cookie domain, and MayAttachToURL implementations that check by origin pattern all interact with this mapping.
diff --git a/content/browser/devtools/protocol/network_handler.cc b/content/browser/devtools/protocol/network_handler.cc
index ca70338f..673fdb0 100644
--- a/content/browser/devtools/protocol/network_handler.cc
+++ b/content/browser/devtools/protocol/network_handler.cc
@@ -15,6 +15,7 @@
 
 #include "base/barrier_closure.h"
 #include "base/base64.h"
+#include "base/check_deref.h"
 #include "base/command_line.h"
 #include "base/containers/queue.h"
 #include "base/containers/span.h"
@@ -312,39 +313,49 @@
 namespace {
 std::vector<net::CanonicalCookie> FilterCookies(
     const std::vector<net::CanonicalCookie>& cookies,
-    const std::string& name,
-    const std::string& normalized_domain,
-    const std::string& path,
-    std::unique_ptr<Network::CookiePartitionKey> partition_key) {
+    const std::optional<std::string>& name,
+    const std::optional<std::string>& normalized_domain,
+    const std::optional<std::string>& path,
+    const Network::CookiePartitionKey* partition_key,
+    bool filter_by_partition_key) {
   std::vector<net::CanonicalCookie> result;
 
   for (const auto& cookie : cookies) {
-    if (cookie.Name() != name)
+    if (name.has_value() && cookie.Name() != name.value()) {
       continue;
-    if (cookie.Domain() != normalized_domain)
+    }
+    if (normalized_domain.has_value() &&
+        cookie.Domain() != normalized_domain.value()) {
       continue;
-    if (!path.empty() && cookie.Path() != path)
-      continue;
-
-    if (!!cookie.PartitionKey() != !!partition_key) {
+    }
+    if (path.has_value() && !path.value().empty() &&
+        cookie.Path() != path.value()) {
       continue;
     }
 
-    if (cookie.PartitionKey().has_value()) {
-      base::expected<net::CookiePartitionKey::SerializedCookiePartitionKey,
-                     std::string>
-          serialized_result =
-              net::CookiePartitionKey::Serialize(cookie.PartitionKey());
-
-      if (!serialized_result.has_value() ||
-          (serialized_result->TopLevelSite() !=
-           partition_key->GetTopLevelSite())) {
+    if (filter_by_partition_key) {
+      if (!!cookie.PartitionKey() != !!partition_key) {
         continue;
       }
 
-      if (serialized_result->has_cross_site_ancestor() !=
-           partition_key->GetHasCrossSiteAncestor()) {
-        continue;
+      if (cookie.PartitionKey().has_value()) {
+        base::expected<net::CookiePartitionKey::SerializedCookiePartitionKey,
+                       std::string>
+            serialized_result =
+                net::CookiePartitionKey::Serialize(cookie.PartitionKey());
+
+        if (!serialized_result.has_value() ||
+            (serialized_result->TopLevelSite() !=
+             const_cast<Network::CookiePartitionKey*>(partition_key)
+                 ->GetTopLevelSite())) {
+          continue;
+        }
+
+        if (serialized_result->has_cross_site_ancestor() !=
+            const_cast<Network::CookiePartitionKey*>(partition_key)
+                ->GetHasCrossSiteAncestor()) {
+          continue;
+        }
       }
     }
 
@@ -354,20 +365,26 @@
   return result;
 }
 
-void DeleteFilteredCookies(
-    network::mojom::CookieManager* cookie_manager,
-    const std::string& name,
-    const std::string& normalized_domain,
-    const std::string& path,
-    std::unique_ptr<Network::CookiePartitionKey> partition_key,
-    std::unique_ptr<DeleteCookiesCallback> callback,
-    const std::vector<net::CanonicalCookie>& cookies) {
-  std::vector<net::CanonicalCookie> filtered_list = FilterCookies(
-      cookies, name, normalized_domain, path, std::move(partition_key));
+void DeleteFilteredCookies(network::mojom::CookieManager* cookie_manager,
+                           const std::optional<std::string>& name,
+                           const std::optional<std::string>& normalized_domain,
+                           const std::optional<std::string>& path,
+                           const Network::CookiePartitionKey* partition_key,
+                           bool filter_by_partition_key,
+                           DevToolsAgentHostClient& client,
+                           bool is_webui,
+                           base::OnceClosure success_callback,
+                           const std::vector<net::CanonicalCookie>& cookies) {
+  std::vector<net::CanonicalCookie> filtered_list =
+      FilterCookies(cookies, name, normalized_domain, path, partition_key,
+                    filter_by_partition_key);
 
-  base::RepeatingClosure barrier_closure = base::BarrierClosure(
-      filtered_list.size(),
-      base::BindOnce(&DeleteCookiesCallback::sendSuccess, std::move(callback)));
+  std::erase_if(filtered_list, [&](const net::CanonicalCookie& cookie) {
+    return !NetworkHandler::CanAccessCookie(client, is_webui, cookie);
+  });
+
+  base::RepeatingClosure barrier_closure =
+      base::BarrierClosure(filtered_list.size(), std::move(success_callback));
 
   for (auto& cookie : filtered_list) {
     cookie_manager->DeleteCanonicalCookie(
@@ -402,20 +419,19 @@
 }  // namespace
 
 std::variant<std::unique_ptr<net::CanonicalCookie>, Response>
-MakeCookieFromProtocolValues(
-    const std::string& name,
-    const std::string& value,
-    const std::string& url_spec,
-    const std::string& domain,
-    const std::string& path,
-    bool secure,
-    bool http_only,
-    const std::string& same_site,
-    double expires,
-    const std::string& priority,
-    const std::optional<std::string>& source_scheme,
-    const std::optional<int>& source_port,
-    std::unique_ptr<Network::CookiePartitionKey>& partition_key) {
+MakeCookieFromProtocolValues(const std::string& name,
+                             const std::string& value,
+                             const std::string& url_spec,
+                             const std::string& domain,
+                             const std::string& path,
+                             bool secure,
+                             bool http_only,
+                             const std::string& same_site,
+                             double expires,
+                             const std::string& priority,
+                             const std::optional<std::string>& source_scheme,
+                             const std::optional<int>& source_port,
+                             const Network::CookiePartitionKey* partition_key) {
   std::string normalized_domain = domain;
 
   if (url_spec.empty() && domain.empty()) {
@@ -472,12 +488,16 @@
     cp = net::CookiePriority::COOKIE_PRIORITY_LOW;
 
   std::optional<net::CookiePartitionKey> cookie_partition_key;
-  if (partition_key && !partition_key->GetTopLevelSite().empty()) {
+  if (partition_key && !const_cast<Network::CookiePartitionKey*>(partition_key)
+                            ->GetTopLevelSite()
+                            .empty()) {
     base::expected<net::CookiePartitionKey, std::string>
         deserialized_partition_key =
             net::CookiePartitionKey::FromUntrustedInput(
-                partition_key->GetTopLevelSite(),
-                partition_key->GetHasCrossSiteAncestor());
+                const_cast<Network::CookiePartitionKey*>(partition_key)
+                    ->GetTopLevelSite(),
+                const_cast<Network::CookiePartitionKey*>(partition_key)
+                    ->GetHasCrossSiteAncestor());
     if (!deserialized_partition_key.has_value()) {
       return Response::InvalidParams(
           "Deserializing cookie partition key failed");
@@ -2383,13 +2403,40 @@
     return;
   }
 
-  storage_partition_->GetCookieManagerForBrowserProcess()->DeleteCookies(
-      network::mojom::CookieDeletionFilter::New(),
-      base::BindOnce([](std::unique_ptr<ClearBrowserCookiesCallback> callback,
-                        uint32_t) { callback->sendSuccess(); },
+  NetworkHandler::ClearCookies(
+      storage_partition_, CHECK_DEREF(client_.get()), host_ && host_->web_ui(),
+      base::BindOnce(&ClearBrowserCookiesCallback::sendSuccess,
                      std::move(callback)));
 }
 
+// static
+void NetworkHandler::ClearCookies(StoragePartition* storage_partition,
+                                  DevToolsAgentHostClient& client,
+                                  bool is_webui,
+                                  base::OnceClosure callback) {
+  auto* cookie_manager = storage_partition->GetCookieManagerForBrowserProcess();
+  cookie_manager->GetAllCookies(
+      base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
+                     /*name=*/std::nullopt, /*normalized_domain=*/std::nullopt,
+                     /*path=*/std::nullopt, /*partition_key=*/nullptr,
+                     /*filter_by_partition_key=*/false, std::ref(client),
+                     is_webui, std::move(callback)));
+}
+
+// static
+bool NetworkHandler::CanAccessCookie(DevToolsAgentHostClient& client,
+                                     bool is_webui,
+                                     const net::CanonicalCookie& cookie) {
+  return client.MayAttachToURL(GURL(base::StrCat({url::kHttpsScheme,
+                                                  url::kStandardSchemeSeparator,
+                                                  cookie.DomainWithoutDot()})),
+                               is_webui) &&
+         client.MayAttachToURL(
+             GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
+                                cookie.DomainWithoutDot()})),
+             is_webui);
+}
+
 void NetworkHandler::GetCookies(std::unique_ptr<Array<String>> protocol_urls,
                                 std::unique_ptr<GetCookiesCallback> callback) {
   if (!host_ || !storage_partition_) {
@@ -2426,14 +2473,8 @@
   bool is_webui = host_ && host_->web_ui();
   std::vector<net::CanonicalCookie> filtered_cookies;
   for (const auto& cookie : cookies) {
-    if (client_->MayAttachToURL(
-            GURL(base::StrCat({url::kHttpsScheme, url::kStandardSchemeSeparator,
-                               cookie.DomainWithoutDot()})),
-            is_webui) &&
-        client_->MayAttachToURL(
-            GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
-                               cookie.DomainWithoutDot()})),
-            is_webui)) {
+    if (NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()), is_webui,
+                                        cookie)) {
       filtered_cookies.emplace_back(std::move(cookie));
     }
   }
@@ -2464,7 +2505,7 @@
       name, value, url.value_or(""), domain.value_or(""), path.value_or(""),
       secure.value_or(false), http_only.value_or(false), same_site.value_or(""),
       expires.value_or(-1), priority.value_or(""), source_scheme, source_port,
-      partition_key);
+      partition_key.get());
 
   if (std::holds_alternative<Response>(cookie_or_error)) {
     callback->sendFailure(std::get<Response>(std::move(cookie_or_error)));
@@ -2474,6 +2515,12 @@
       std::get<std::unique_ptr<net::CanonicalCookie>>(
           std::move(cookie_or_error));
 
+  if (!NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()),
+                                       host_ && host_->web_ui(), *cookie)) {
+    callback->sendFailure(Response::ServerError("Permission denied"));
+    return;
+  }
+
   net::CookieOptions options;
   // Permit it to set a SameSite cookie if it wants to.
   options.set_same_site_cookie_context(
@@ -2491,6 +2538,8 @@
 void NetworkHandler::SetCookies(
     StoragePartition* storage_partition,
     std::unique_ptr<protocol::Array<Network::CookieParam>> cookies,
+    DevToolsAgentHostClient& client,
+    bool is_webui,
     base::OnceCallback<void(bool)> callback) {
   std::vector<std::unique_ptr<net::CanonicalCookie>> net_cookies;
   for (const std::unique_ptr<Network::CookieParam>& cookie : *cookies) {
@@ -2523,15 +2572,20 @@
         cookie->GetDomain(""), cookie->GetPath(""), cookie->GetSecure(false),
         cookie->GetHttpOnly(false), cookie->GetSameSite(""),
         cookie->GetExpires(-1), cookie->GetPriority(""), source_scheme,
-        source_port, partition_key);
+        source_port, partition_key.get());
     if (std::holds_alternative<Response>(net_cookie_or_error)) {
       // TODO: Investiage whether we can report the error as a protocol error
       // (this might be a breaking CDP change).
       std::move(callback).Run(false);
       return;
     }
-    net_cookies.push_back(std::get<std::unique_ptr<net::CanonicalCookie>>(
-        std::move(net_cookie_or_error)));
+    auto net_cookie = std::get<std::unique_ptr<net::CanonicalCookie>>(
+        std::move(net_cookie_or_error));
+    if (!NetworkHandler::CanAccessCookie(client, is_webui, *net_cookie)) {
+      std::move(callback).Run(false);
+      return;
+    }
+    net_cookies.push_back(std::move(net_cookie));
   }
 
   base::RepeatingClosure barrier_closure = base::BarrierClosure(
@@ -2560,9 +2614,11 @@
     callback->sendFailure(Response::InternalError());
     return;
   }
+  CHECK(host_);
 
   NetworkHandler::SetCookies(
-      storage_partition_, std::move(cookies),
+      storage_partition_, std::move(cookies), CHECK_DEREF(client_.get()),
+      host_->web_ui(),
       base::BindOnce(
           [](std::unique_ptr<SetCookiesCallback> callback, bool success) {
             if (success) {
@@ -2605,10 +2661,14 @@
   auto* cookie_manager =
       storage_partition_->GetCookieManagerForBrowserProcess();
 
-  cookie_manager->GetAllCookies(
-      base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
-                     name, normalized_domain, path.value_or(""),
-                     std::move(partition_key), std::move(callback)));
+  cookie_manager->GetAllCookies(base::BindOnce(
+      &DeleteFilteredCookies, base::Unretained(cookie_manager), name,
+      normalized_domain, path.value_or(""),
+      base::Owned(partition_key.release()),
+      /*filter_by_partition_key=*/true, std::ref(CHECK_DEREF(client_.get())),
+      host_ && host_->web_ui(),
+      base::BindOnce(&DeleteCookiesCallback::sendSuccess,
+                     std::move(callback))));
 }
 
 Response NetworkHandler::SetExtraHTTPHeaders(
diff --git a/content/browser/devtools/protocol/network_handler.h b/content/browser/devtools/protocol/network_handler.h
index 24dce87..8e7bcfb3 100644
--- a/content/browser/devtools/protocol/network_handler.h
+++ b/content/browser/devtools/protocol/network_handler.h
@@ -116,8 +116,19 @@
   static void SetCookies(
       StoragePartition* storage_partition,
       std::unique_ptr<protocol::Array<Network::CookieParam>> cookies,
+      DevToolsAgentHostClient& client,
+      bool is_webui,
       base::OnceCallback<void(bool)> callback);
 
+  static void ClearCookies(StoragePartition* storage_partition,
+                           DevToolsAgentHostClient& client,
+                           bool is_webui,
+                           base::OnceClosure callback);
+
+  static bool CanAccessCookie(DevToolsAgentHostClient& client,
+                              bool is_webui,
+                              const net::CanonicalCookie& cookie);
+
   void Wire(UberDispatcher* dispatcher) override;
   void SetRenderer(int render_process_id,
                    RenderFrameHostImpl* frame_host) override;
diff --git a/content/browser/devtools/protocol/storage_handler.cc b/content/browser/devtools/protocol/storage_handler.cc
index b33e82dd..761a1e3 100644
--- a/content/browser/devtools/protocol/storage_handler.cc
+++ b/content/browser/devtools/protocol/storage_handler.cc
@@ -15,6 +15,7 @@
 #include <vector>
 
 #include "base/barrier_closure.h"
+#include "base/check_deref.h"
 #include "base/functional/bind.h"
 #include "base/notreached.h"
 #include "base/scoped_observation.h"
@@ -491,14 +492,8 @@
   bool is_webui = frame_host_ && frame_host_->web_ui();
   std::vector<net::CanonicalCookie> filtered_cookies;
   for (const auto& cookie : cookies) {
-    if (client_->MayAttachToURL(
-            GURL(base::StrCat({url::kHttpsScheme, url::kStandardSchemeSeparator,
-                               cookie.DomainWithoutDot()})),
-            is_webui) &&
-        client_->MayAttachToURL(
-            GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
-                               cookie.DomainWithoutDot()})),
-            is_webui)) {
+    if (NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()), is_webui,
+                                        cookie)) {
       filtered_cookies.emplace_back(std::move(cookie));
     }
   }
@@ -518,7 +513,8 @@
   }
 
   NetworkHandler::SetCookies(
-      storage_partition, std::move(cookies),
+      storage_partition, std::move(cookies), CHECK_DEREF(client_.get()),
+      frame_host_ && frame_host_->web_ui(),
       base::BindOnce(
           [](std::unique_ptr<SetCookiesCallback> callback, bool success) {
             if (success) {
@@ -542,11 +538,10 @@
     return;
   }
 
-  storage_partition->GetCookieManagerForBrowserProcess()->DeleteCookies(
-      network::mojom::CookieDeletionFilter::New(),
-      base::BindOnce([](std::unique_ptr<ClearCookiesCallback> callback,
-                        uint32_t) { callback->sendSuccess(); },
-                     std::move(callback)));
+  NetworkHandler::ClearCookies(
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
index 9e29f4d0..ac7cb63d 100644
--- a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
+++ b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
@@ -2764,6 +2764,76 @@
   EXPECT_EQ(2u, found);
 }
 
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, CookiePermissions) {
+  SetNotAttachableHosts({"b.test"});
+  content::SetupCrossSiteRedirector(embedded_test_server());
+  ASSERT_TRUE(embedded_test_server()->Start());
+
+  std::string cookies_to_set = "/set-cookie?foo=bar";
+  GURL url = embedded_test_server()->GetURL("b.test", cookies_to_set);
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  url = embedded_test_server()->GetURL("a.test", cookies_to_set);
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+
+  Attach();
+
+  // Try to set a cookie on b.test via protocol.
+  base::DictValue set_params;
+  set_params.Set("name", "proto_cookie");
+  set_params.Set("value", "proto_val");
+  set_params.Set("domain", "b.test");
+  set_params.Set("path", "/");
+  SendCommandSync("Network.setCookie", std::move(set_params));
+  EXPECT_THAT(
+      error()->FindInt("code"),
+      testing::Optional(static_cast<int>(crdtp::DispatchCode::SERVER_ERROR)));
+  EXPECT_EQ(*error()->FindString("message"), "Permission denied");
+
+  // Try to set cookies on b.test via protocol.
+  base::DictValue set_cookies_params;
+  base::ListValue cookies_list;
+  base::DictValue cookie;
+  cookie.Set("name", "proto_cookie_2");
+  cookie.Set("value", "val");
+  cookie.Set("domain", "b.test");
+  cookie.Set("path", "/");
+  cookies_list.Append(std::move(cookie));
+  set_cookies_params.Set("cookies", std::move(cookies_list));
+  SendCommandSync("Network.setCookies", std::move(set_cookies_params));
+  EXPECT_THAT(
+      error()->FindInt("code"),
+      testing::Optional(static_cast<int>(crdtp::DispatchCode::INVALID_PARAMS)));
+  EXPECT_EQ(*error()->FindString("message"), "Invalid cookie fields");
+
+  // Try to delete cookie on b.test via protocol.
+  base::DictValue del_params;
+  del_params.Set("name", "foo");
+  del_params.Set("domain", "b.test");
+  SendCommandSync("Network.deleteCookies", std::move(del_params));
+  EXPECT_FALSE(error());
+
+  // Try to clear browser cookies.
+  SendCommandSync("Network.clearBrowserCookies");
+  EXPECT_FALSE(error());
+
+  // Verify a.test cookie is gone.
+  const base::ListValue* cookies =
+      SendCommandSync("Network.getAllCookies")->FindList("cookies");
+  ASSERT_TRUE(cookies);
+  EXPECT_EQ(0u, cookies->size());
+
+  Detach();
+
+  // Verify b.test cookie is still there.
+  GURL url_b_echo =
+      embedded_test_server()->GetURL("b.test", "/echoheader?Cookie");
+  EXPECT_TRUE(NavigateToURL(shell(), url_b_echo));
+  std::string content =
+      EvalJs(shell()->web_contents(), "document.body.innerText")
+          .ExtractString();
+  EXPECT_THAT(content, testing::HasSubstr("foo=bar"));
+}
+
 IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
                        ReturnsCookiesOnlyForAttachableUrls) {
   SetNotAttachableHosts({"b.test"});
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt b/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
index 30ccb31..21690bf 100644
--- a/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
@@ -22,7 +22,7 @@
 {
     error : {
         code : -32602
-        message : Failed to find browser context for id invalid
+        message : browserContextId is only allowed for Browser target
     }
     id : <number>
     sessionId : <string>
Loading diff…

Original Bug Report

reported by [email protected]

DevTools cookie write/delete APIs allow extensions to modify cookies for enterprise policy-blocked sites despite runtime_blocked_hosts restrictions


Report description

DevTools cookie write/delete APIs allow extensions to modify cookies for enterprise policy-blocked sites despite runtime_blocked_hosts restrictions


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/


The problem

Please describe the technical details of the vulnerability

1. technical details

Chromium enforces enterprise host restrictions for extensions via runtime_blocked_hosts in the ExtensionSettings policy. On Windows this can be configured, for example, as:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\Software\Policies\Google\Chrome\ExtensionSettings]
"*"="{\"runtime_blocked_hosts\":[\"https://www.google.com/*\"]}"

This is intended to ensure that extensions cannot interact with https://www.google.com/*, even if they have powerful permissions like debugger.

When an extension uses the chrome.debugger API, the DevTools client for extensions (ExtensionDevToolsClientHost) consults ExtensionMayAttachToURL / ExtensionMayAttachToURLOrInnerURL, which explicitly treats policy-blocked hosts as forbidden:

// chrome/browser/extensions/api/debugger/debugger_api.cc
bool ExtensionMayAttachToURL(const Extension& extension,
                             Profile* extension_profile,
                             const GURL& url,
                             std::string* error) {
  ...
  const GURL& url_for_restriction_check =
      url.SchemeIsBlob() ? url::Origin::Create(url).GetURL() : url;
  if (extension.permissions_data()->IsRestrictedUrl(url_for_restriction_check,
                                                    error)) {
    return false;
  }

  // Policy blocked hosts supersede the `debugger` permission.
  if (extension.permissions_data()->IsPolicyBlockedHost(url) ||
      extension.permissions_data()->IsPolicyBlockedHost(
          url_for_restriction_check)) {
    *error = kRestrictedError;
    return false;
  }
  ...
}

This correctly prevents an extension from attaching DevTools directly to a tab whose URL matches runtime_blocked_hosts (such as https://www.google.com).

However, once an extension is attached to any allowed tab, it can still use DevTools Protocol (CDP) commands that operate on the global cookie jar and supply arbitrary target URLs or domains. For cookie read operations, the implementation has been updated to use the DevTools client’s MayAttachToURL check when deciding which hosts are accessible:

// content/browser/devtools/protocol/network_handler.cc
void NetworkHandler::GetCookies(
    std::unique_ptr<Array<String>> protocol_urls,
    std::unique_ptr<GetCookiesCallback> callback) {
  ...
  std::vector<GURL> urls = ComputeCookieURLs(host_, protocol_urls);
  bool is_webui = host_ && host_->web_ui();

  std::erase_if(urls, [=, this](const GURL& url) {
    return !client_->MayAttachToURL(url, is_webui);
  });
  ...
}

and similarly for GetAllCookies, which filters the results using synthesized HTTP/HTTPS URLs per cookie domain. This ensures that enterprise host restrictions are applied to cookie enumeration.

In contrast, cookie write/delete/clear DevTools handlers do not perform any MayAttachToURL or policy-based checks on the target url or domain. They act only on the presence of a StoragePartition and the syntactic validity of the cookie parameters:

// content/browser/devtools/protocol/network_handler.cc
void NetworkHandler::SetCookie(
    const std::string& name,
    const std::string& value,
    std::optional<std::string> url,
    std::optional<std::string> domain,
    std::optional<std::string> path,
    std::optional<bool> secure,
    std::optional<bool> http_only,
    std::optional<std::string> same_site,
    std::optional<double> expires,
    std::optional<std::string> priority,
    std::optional<bool> same_party,
    std::optional<std::string> source_scheme,
    std::optional<int> source_port,
    std::unique_ptr<Network::CookiePartitionKey> partition_key,
    std::unique_ptr<SetCookieCallback> callback) {
  if (!storage_partition_) {
    callback->sendFailure(Response::InternalError());
    return;
  }

  auto cookie_or_error = MakeCookieFromProtocolValues(
      name, value, url.value_or(""), domain.value_or(""), path.value_or(""),
      secure.value_or(false), http_only.value_or(false), same_site.value_or(""),
      expires.value_or(-1), priority.value_or(""), source_scheme, source_port,
      partition_key);
  ...
  storage_partition_->GetCookieManagerForBrowserProcess()->SetCanonicalCookie(
      *cookie, net::cookie_util::SimulatedCookieSource(*cookie, "https"),
      options,
      base::BindOnce(net::cookie_util::IsCookieAccessResultInclude)
          .Then(base::BindOnce(&SetCookieCallback::sendSuccess,
                               std::move(callback))));
}

void NetworkHandler::DeleteCookies(
    const std::string& name,
    std::optional<std::string> url_spec,
    std::optional<std::string> domain,
    std::optional<std::string> path,
    std::unique_ptr<Network::CookiePartitionKey> partition_key,
    std::unique_ptr<DeleteCookiesCallback> callback) {
  if (!storage_partition_) {
    callback->sendFailure(Response::InternalError());
    return;
  }
  if (!url_spec.has_value() && !domain.has_value()) {
    callback->sendFailure(Response::InvalidParams(
        "At least one of the url and domain needs to be specified"));
  }
  ...
  cookie_manager->GetAllCookies(
      base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
                     name, normalized_domain, path.value_or(""),
                     std::move(partition_key), std::move(callback)));
}

The storage domain DevTools handler exposes a similar cookie-clearing operation without policy checks:

// content/browser/devtools/protocol/storage_handler.cc
void StorageHandler::ClearCookies(
    std::optional<std::string> browser_context_id,
    std::unique_ptr<ClearCookiesCallback> callback) {
  StoragePartition* storage_partition = nullptr;
  Response response = StorageHandler::FindStoragePartition(browser_context_id,
                                                           &storage_partition);
  if (!response.IsSuccess()) {
    callback->sendFailure(std::move(response));
    return;
  }

  storage_partition->GetCookieManagerForBrowserProcess()->DeleteCookies(
      network::mojom::CookieDeletionFilter::New(),
      base::BindOnce([](std::unique_ptr<ClearCookiesCallback> callback,
                        uint32_t) { callback->sendSuccess(); },
                     std::move(callback)));
}

As a result, once an extension is attached to a tab that is not policy-blocked, it can use chrome.debugger.sendCommand to invoke:

  • Network.setCookie with url: "https://www.google.com/", to create or overwrite a cookie on a host that is policy-blocked for the extension.
  • Network.deleteCookies with domain: "www.google.com", to remove specific cookies for that host.
  • Storage.clearCookies, to clear all cookies in the current profile’s cookie store, including those for policy-blocked sites such as https://www.google.com/.

The PoC extension in web/devtools_cookies/extension demonstrates this pattern. Its popup script attaches to the active tab and then calls DevTools cookie commands targeting https://www.google.com/:

// web/devtools_cookies/extension/popup.js
const PROTOCOL_VERSION = '1.3';
const TARGET_URL = 'https://www.google.com/';
const TARGET_DOMAIN = 'www.google.com';
...
function sendCommand(method, params) {
  ...
  chrome.debugger.sendCommand(debuggee, method, params, (result) => {
    ...
  });
}

document.getElementById('setCookie').addEventListener('click', () => {
  ensureAttached((ok) => {
    if (!ok) return;
    const params = {
      name: 'devtools_poc',
      value: 'ts_' + Date.now(),
      url: TARGET_URL,
      path: '/',
      secure: true,
      httpOnly: false
    };
    sendCommand('Network.setCookie', params);
  });
});

The extension only requires the debugger permission and user interaction to attach; it does not require host permissions for https://www.google.com/, and the enterprise runtime_blocked_hosts policy entry does not prevent these DevTools cookie operations.

Overall, this creates a discrepancy: cookie reads respect runtime_blocked_hosts via MayAttachToURL, while cookie writes/deletes/clears do not, allowing extensions to mutate cookie state on policy-blocked hosts.

2. vulnerability reproduction

  1. On Windows, import the provided policy file:
    • Double-click web/devtools_cookies/policy_runtime_blocked_hosts_google.reg to add the ExtensionSettings policy under HKCU\Software\Policies\Google\Chrome.
    • Restart Chrome and verify on chrome://policy that ExtensionSettings is present and runtime_blocked_hosts contains https://www.google.com/*.
  2. In Chrome, open chrome://extensions, enable Developer mode, and load the unpacked extension from web/devtools_cookies/extension.
  3. Open any tab that is not https://www.google.com (for example, https://example.com).
  4. Click the extension’s toolbar icon:
    • Click “Attach to active tab” (step 1) to call chrome.debugger.attach for the current tab.
    • Click “Set poc cookie on https://www.google.com/” (step 2). The extension invokes Network.setCookie with url: "https://www.google.com/".
    • Optionally click “Delete poc cookie on https://www.google.com/” (step 3) and “Clear all cookies in current profile” (step 4), which invoke Network.deleteCookies and Storage.clearCookies.
  5. In a separate tab, visit https://www.google.com (or clear and then visit it) and observe that:
    • The devtools_poc cookie is present or has been removed according to the actions performed by the extension.
    • If you were logged in, removing or clearing cookies can force sign-out and disrupt the session.

These steps show that an extension subject to runtime_blocked_hosts: ["https://www.google.com/*"] can still modify (set, delete, or clear) cookies for https://www.google.com/ via DevTools cookie APIs, even though direct attachment to that URL is correctly blocked.

Impact analysis

  • Who can exploit it: Any installed extension that declares the debugger permission and can run in an environment where administrators configure runtime_blocked_hosts to restrict host access (for example, adding https://www.google.com/*). A user must interact with the extension (e.g., click its action) to allow it to attach to some non-policy-blocked tab via chrome.debugger.attach.

  • What they gain: Once attached to an allowed tab, the extension can use DevTools cookie APIs (Network.setCookie, Network.deleteCookies, Storage.clearCookies) to modify cookies for hosts that are policy-blocked for that extension, including https://www.google.com/. Practically, this enables:

    • Injecting or overwriting cookies on policy-blocked sites (which may influence site behavior or application state, and could become more serious in combination with site-side weaknesses such as session fixation vulnerabilities).
    • Deleting specific cookies or clearing all cookies, forcing sign-outs and disrupting user sessions on policy-blocked sites.
  • Security significance (classification): This is best categorized as a Permissions Bypass / enterprise policy enforcement bypass for extension host restrictions. It does not grant the ability to read cookie values that are otherwise protected, nor does it provide remote code execution, a sandbox escape, or a privilege escalation beyond the extension’s existing capabilities. However, it weakens administrator expectations that runtime_blocked_hosts completely prevents extensions from influencing sensitive sites, by allowing extensions to tamper with cookie-based session and configuration state on those policy-blocked hosts.


The cause

What version of Chrome have you found the security issue in?

145.0.7632.1/stable

No, it is not related to a crash.

Choose the type of vulnerability

Permissions Bypass

How would you like to be publicly acknowledged for your report?

Povcfe of Tencent Security Xuanwu Lab

View on issue tracker