Medium chrome Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper access control in Workers
DescriptionImproper access control in Workers
ComponentWorkers
Bug ClassLogic Error
Tracker513836495
Fix commitcd4f000a7983 (chromium/src) +165/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • content/browser/browsing_data/clear_site_data_handler_browsertest.cc
  • content/browser/devtools/service_worker_devtools_agent_host.cc
  • content/browser/service_worker/embedded_worker_instance.cc
  • content/browser/storage_partition_impl.cc
From cd4f000a79833453cf3333762f9caa74c855e485 Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <[email protected]>
Date: Tue, 18 Aug 2026 22:26:38 -0700
Subject: [PATCH] Scope worker Clear-Site-Data deletions to the worker StorageKey

When a service or shared worker subresource load receives a
Clear-Site-Data header, the per-worker URLLoaderNetworkServiceObserver
binding has no NavigationOrDocumentHandle, so
StoragePartitionImpl::CalculateStorageKey() returned nullopt and the
resulting BrowsingDataFilterBuilder matched the response origin's
first-party partition plus every partition with that origin as
top-level site, instead of only the worker's own partition. The same
fetch from a document is already narrowed via
RenderFrameHostImpl::CalculateStorageKey().

Plumb the worker's blink::StorageKey through
CreateURLLoaderNetworkObserverForServiceOrSharedWorker() into the
URLLoaderNetworkContext, and have CalculateStorageKey() derive the
response origin's key from it via StorageKey::WithOrigin() for worker
contexts. EmbeddedWorkerInstance, SharedWorkerHost and
ServiceWorkerDevToolsAgentHost pass the key; callers that lack one
(browser-initiated, WebSocket, WebTransport) keep the existing
behaviour via the default nullopt.

Add a browser test that registers a service worker, fetches same- and
cross-origin Clear-Site-Data responses from inside it, and verifies
the BrowsingDataRemover filter carries a storage key whose top-level
site matches the worker's.

Bug: 513836495
Change-Id: I6d6542835186a1076be47cce7fd97123d9719fe3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8247958
Reviewed-by: Martin Šrámek <[email protected]>
Commit-Queue: Ming-Ying Chung <[email protected]>
Reviewed-by: Danil Somsikov <[email protected]>
Reviewed-by: Mingyu Lei <[email protected]>
Reviewed-by: Ben Kelly <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1682035}
---

diff --git a/content/browser/browsing_data/clear_site_data_handler_browsertest.cc b/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
index 54c862ca..8354cd7 100644
--- a/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
+++ b/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
@@ -682,6 +682,103 @@
   delegate()->VerifyAndClearExpectations();
 }
 
+// Verifies that when a same-origin subresource fetch initiated by a service
+// worker receives a `Clear-Site-Data` header, the deletion filter is scoped to
+// the worker's `blink::StorageKey` (using the worker's top-level site).
+//
+// The test uses `"cache"` because `"storage"` deletion unregisters and
+// terminates the active service worker instance, preventing it from completing
+// the fetch and reporting the result back to the page. Using `"cache"` covers
+// the same `blink::StorageKey` derivation path as `"storage"` because
+// `StoragePartitionImpl::CalculateStorageKey()` resolves the key independently
+// of the cleared data types. Comparing this test with the cross-origin test
+// below verifies that same-origin requests retain a same-site ancestor chain
+// bit while cross-origin requests are scoped as cross-site under the worker's
+// top-level site.
+IN_PROC_BROWSER_TEST_F(ClearSiteDataHandlerBrowserTest,
+                       ServiceWorkerSameOriginFetchUsesWorkerStorageKey) {
+  GURL origin1 = https_server()->GetURL("origin1.com", "/");
+
+  GURL url = origin1;
+  AddQuery(&url, "file", "worker_setup.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  WaitForTitle(shell(), "service worker is ready");
+
+  const char kFetchScript[] =
+      "(async () => {"
+      "  const reg = await navigator.serviceWorker.ready;"
+      "  return new Promise(resolve => {"
+      "    const channel = new MessageChannel();"
+      "    channel.port1.onmessage = e => {"
+      "      resolve(e.data.status === 200 || e.data.status === 0);"
+      "    };"
+      "    reg.active.postMessage({action: 'fetch', url: $1, mode: 'no-cors'},"
+      "                           [channel.port2]);"
+      "  });"
+      "})();";
+
+  GURL same_origin_url = https_server()->GetURL("origin1.com", "/resource");
+  AddQuery(&same_origin_url, "header", "\"cache\"");
+
+  // Expect a deletion call for `origin1.com` matching the `"cache"` directive
+  // in the `Clear-Site-Data` response header. The deletion filter's top-level
+  // site partition must match the worker's top-level site (`origin1.com`).
+  delegate()->ExpectClearSiteDataCall(
+      storage_partition_config(), url::Origin::Create(origin1),
+      net::SchemefulSite(origin1),
+      /*cookies=*/false, /*storage=*/false, /*cache=*/true);
+
+  EXPECT_TRUE(
+      EvalJs(shell()->web_contents(), JsReplace(kFetchScript, same_origin_url))
+          .ExtractBool());
+  delegate()->VerifyAndClearExpectations();
+}
+
+// Verifies that when a cross-origin subresource fetch initiated by a service
+// worker receives a `Clear-Site-Data` header, the deletion filter is scoped to
+// the target origin while preserving the worker's top-level site partition.
+IN_PROC_BROWSER_TEST_F(ClearSiteDataHandlerBrowserTest,
+                       ServiceWorkerCrossOriginFetchUsesWorkerStorageKey) {
+  GURL origin1 = https_server()->GetURL("origin1.com", "/");
+  GURL origin2 = https_server()->GetURL("origin2.com", "/");
+
+  GURL url = origin1;
+  AddQuery(&url, "file", "worker_setup.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  WaitForTitle(shell(), "service worker is ready");
+
+  const char kFetchScript[] =
+      "(async () => {"
+      "  const reg = await navigator.serviceWorker.ready;"
+      "  return new Promise(resolve => {"
+      "    const channel = new MessageChannel();"
+      "    channel.port1.onmessage = e => {"
+      "      resolve(e.data.status === 200 || e.data.status === 0);"
+      "    };"
+      "    reg.active.postMessage({action: 'fetch', url: $1, mode: 'no-cors'},"
+      "                           [channel.port2]);"
+      "  });"
+      "})();";
+
+  GURL cross_origin_url = https_server()->GetURL("origin2.com", "/resource");
+  AddQuery(&cross_origin_url, "header", "\"storage\"");
+
+  // Expect a deletion call for target origin `origin2.com` matching the
+  // `"storage"` directive in the `Clear-Site-Data` response header. Although
+  // the fetch target is cross-origin, the deletion filter's top-level site
+  // partition must remain `origin1.com` (the hosting worker's site) to preserve
+  // third-party partition isolation.
+  delegate()->ExpectClearSiteDataCall(
+      storage_partition_config(), url::Origin::Create(cross_origin_url),
+      net::SchemefulSite(origin1),
+      /*cookies=*/false, /*storage=*/true, /*cache=*/false);
+
+  EXPECT_TRUE(
+      EvalJs(shell()->web_contents(), JsReplace(kFetchScript, cross_origin_url))
+          .ExtractBool());
+  delegate()->VerifyAndClearExpectations();
+}
+
 // Tests that Clear-Site-Data is only executed on a resource fetch
 // if credentials are allowed in that fetch.
 
diff --git a/content/browser/devtools/service_worker_devtools_agent_host.cc b/content/browser/devtools/service_worker_devtools_agent_host.cc
index 66c6726..1518802e 100644
--- a/content/browser/devtools/service_worker_devtools_agent_host.cc
+++ b/content/browser/devtools/service_worker_devtools_agent_host.cc
@@ -471,7 +471,7 @@
       /*dip_reporter=*/mojo::NullRemote(),
       static_cast<StoragePartitionImpl*>(rph->GetStoragePartition())
           ->CreateURLLoaderNetworkObserverForServiceOrSharedWorker(
-              ToOriginatingProcessId(rph->GetID()), origin),
+              ToOriginatingProcessId(rph->GetID()), origin, version->key()),
       NetworkServiceDevToolsObserver::MakeSelfOwned(GetId()),
       /*client_security_state=*/nullptr,
       /*network_restrictions_id=*/version->network_restrictions_id(),
diff --git a/content/browser/service_worker/embedded_worker_instance.cc b/content/browser/service_worker/embedded_worker_instance.cc
index 89bc304..95051090 100644
--- a/content/browser/service_worker/embedded_worker_instance.cc
+++ b/content/browser/service_worker/embedded_worker_instance.cc
@@ -887,7 +887,7 @@
           std::move(dip_reporter),
           static_cast<StoragePartitionImpl*>(rph->GetStoragePartition())
               ->CreateURLLoaderNetworkObserverForServiceOrSharedWorker(
-                  ToOriginatingProcessId(rph->GetID()), origin),
+                  ToOriginatingProcessId(rph->GetID()), origin, storage_key),
           NetworkServiceDevToolsObserver::MakeSelfOwned(devtools_worker_token),
           std::move(client_security_state), network_restrictions_id,
           "EmbeddedWorkerInstance::CreateFactoryBundle",
diff --git a/content/browser/storage_partition_impl.cc b/content/browser/storage_partition_impl.cc
index d288ee2aa..d3fb23e 100644
--- a/content/browser/storage_partition_impl.cc
+++ b/content/browser/storage_partition_impl.cc
@@ -2682,12 +2682,13 @@
 mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
 StoragePartitionImpl::CreateURLLoaderNetworkObserverForServiceOrSharedWorker(
     const network::OriginatingProcessId& process_id,
-    const url::Origin& worker_origin) {
+    const url::Origin& worker_origin,
+    const std::optional<blink::StorageKey>& storage_key) {
   mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver> remote;
   url_loader_network_observers_.Add(
       this, remote.InitWithNewPipeAndPassReceiver(),
-      URLLoaderNetworkContext::CreateForServiceOrSharedWorker(process_id,
-                                                              worker_origin));
+      URLLoaderNetworkContext::CreateForServiceOrSharedWorker(
+          process_id, worker_origin, storage_key));
   return remote;
 }
 
@@ -3686,8 +3687,21 @@
     return std::nullopt;
   }
 
-  NavigationOrDocumentHandle* handle =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/browsing_data/clear_site_data_handler_browsertest.cc b/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
index 54c862ca..8354cd7 100644
--- a/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
+++ b/content/browser/browsing_data/clear_site_data_handler_browsertest.cc
@@ -682,6 +682,103 @@
   delegate()->VerifyAndClearExpectations();
 }
 
+// Verifies that when a same-origin subresource fetch initiated by a service
+// worker receives a `Clear-Site-Data` header, the deletion filter is scoped to
+// the worker's `blink::StorageKey` (using the worker's top-level site).
+//
+// The test uses `"cache"` because `"storage"` deletion unregisters and
+// terminates the active service worker instance, preventing it from completing
+// the fetch and reporting the result back to the page. Using `"cache"` covers
+// the same `blink::StorageKey` derivation path as `"storage"` because
+// `StoragePartitionImpl::CalculateStorageKey()` resolves the key independently
+// of the cleared data types. Comparing this test with the cross-origin test
+// below verifies that same-origin requests retain a same-site ancestor chain
+// bit while cross-origin requests are scoped as cross-site under the worker's
+// top-level site.
+IN_PROC_BROWSER_TEST_F(ClearSiteDataHandlerBrowserTest,
+                       ServiceWorkerSameOriginFetchUsesWorkerStorageKey) {
+  GURL origin1 = https_server()->GetURL("origin1.com", "/");
+
+  GURL url = origin1;
+  AddQuery(&url, "file", "worker_setup.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  WaitForTitle(shell(), "service worker is ready");
+
+  const char kFetchScript[] =
+      "(async () => {"
+      "  const reg = await navigator.serviceWorker.ready;"
+      "  return new Promise(resolve => {"
+      "    const channel = new MessageChannel();"
+      "    channel.port1.onmessage = e => {"
+      "      resolve(e.data.status === 200 || e.data.status === 0);"
+      "    };"
+      "    reg.active.postMessage({action: 'fetch', url: $1, mode: 'no-cors'},"
+      "                           [channel.port2]);"
+      "  });"
+      "})();";
+
+  GURL same_origin_url = https_server()->GetURL("origin1.com", "/resource");
+  AddQuery(&same_origin_url, "header", "\"cache\"");
+
+  // Expect a deletion call for `origin1.com` matching the `"cache"` directive
+  // in the `Clear-Site-Data` response header. The deletion filter's top-level
+  // site partition must match the worker's top-level site (`origin1.com`).
+  delegate()->ExpectClearSiteDataCall(
+      storage_partition_config(), url::Origin::Create(origin1),
+      net::SchemefulSite(origin1),
+      /*cookies=*/false, /*storage=*/false, /*cache=*/true);
+
+  EXPECT_TRUE(
+      EvalJs(shell()->web_contents(), JsReplace(kFetchScript, same_origin_url))
+          .ExtractBool());
+  delegate()->VerifyAndClearExpectations();
+}
+
+// Verifies that when a cross-origin subresource fetch initiated by a service
+// worker receives a `Clear-Site-Data` header, the deletion filter is scoped to
+// the target origin while preserving the worker's top-level site partition.
+IN_PROC_BROWSER_TEST_F(ClearSiteDataHandlerBrowserTest,
+                       ServiceWorkerCrossOriginFetchUsesWorkerStorageKey) {
+  GURL origin1 = https_server()->GetURL("origin1.com", "/");
+  GURL origin2 = https_server()->GetURL("origin2.com", "/");
+
+  GURL url = origin1;
+  AddQuery(&url, "file", "worker_setup.html");
+  EXPECT_TRUE(NavigateToURL(shell(), url));
+  WaitForTitle(shell(), "service worker is ready");
+
+  const char kFetchScript[] =
+      "(async () => {"
+      "  const reg = await navigator.serviceWorker.ready;"
+      "  return new Promise(resolve => {"
+      "    const channel = new MessageChannel();"
+      "    channel.port1.onmessage = e => {"
+      "      resolve(e.data.status === 200 || e.data.status === 0);"
+      "    };"
+      "    reg.active.postMessage({action: 'fetch', url: $1, mode: 'no-cors'},"
+      "                           [channel.port2]);"
+      "  });"
+      "})();";
+
+  GURL cross_origin_url = https_server()->GetURL("origin2.com", "/resource");
+  AddQuery(&cross_origin_url, "header", "\"storage\"");
+
+  // Expect a deletion call for target origin `origin2.com` matching the
+  // `"storage"` directive in the `Clear-Site-Data` response header. Although
+  // the fetch target is cross-origin, the deletion filter's top-level site
+  // partition must remain `origin1.com` (the hosting worker's site) to preserve
+  // third-party partition isolation.
+  delegate()->ExpectClearSiteDataCall(
+      storage_partition_config(), url::Origin::Create(cross_origin_url),
+      net::SchemefulSite(origin1),
+      /*cookies=*/false, /*storage=*/true, /*cache=*/false);
+
+  EXPECT_TRUE(
+      EvalJs(shell()->web_contents(), JsReplace(kFetchScript, cross_origin_url))
+          .ExtractBool());
+  delegate()->VerifyAndClearExpectations();
+}
+
 // Tests that Clear-Site-Data is only executed on a resource fetch
 // if credentials are allowed in that fetch.
diff --git a/content/test/data/browsing_data/worker.js b/content/test/data/browsing_data/worker.js
index d2915648..32e1d89d 100644
--- a/content/test/data/browsing_data/worker.js
+++ b/content/test/data/browsing_data/worker.js
@@ -72,3 +72,26 @@
     { 'headers': { 'Content-Type': 'text/html' } }
   ));
 });
+
+self.addEventListener('message', function(event) {
+  if (event.data && event.data.action === 'fetch') {
+    event.waitUntil((async () => {
+      try {
+        const res = await fetch(
+            event.data.url,
+            {credentials: 'include', mode: event.data.mode || 'cors'});
+        if (event.ports && event.ports[0]) {
+          event.ports[0].postMessage({status: res.status});
+        } else if (event.source) {
+          event.source.postMessage({status: res.status});
+        }
+      } catch (e) {
+        if (event.ports && event.ports[0]) {
+          event.ports[0].postMessage({error: e.toString()});
+        } else if (event.source) {
+          event.source.postMessage({error: e.toString()});
+        }
+      }
+    })());
+  }
+});
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.