Medium watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper access control in Workers
DescriptionImproper access control in Workers
ComponentWorkers
Bug ClassLogic Error
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.
Tracker513836495
Fix commitcd4f000a7983 (chromium/src) +165/-17
CWECWE-284
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-08-19 (public git)
Disclosed2026-08-25
Patch gap6 days — fix public in source before the advisory

Background

`Clear-Site-Data`
an HTTP response header that instructs the browser to delete cookies, storage, and/or cache associated with the responding origin.
`blink::StorageKey`
the partitioning key (origin plus top-level site and ancestor-chain bit) that isolates a document’s or worker’s storage into a specific partition.
`StoragePartitionImpl::CalculateStorageKey()`
the browser-side function that derives the StorageKey used to build the BrowsingDataFilterBuilder for a Clear-Site-Data deletion.
`URLLoaderNetworkContext` / `SharedOrServiceWorkerContext`
the per-request network-observer context; the worker variant previously carried only a process_id and worker_origin, with no NavigationOrDocumentHandle.

Root Cause Analysis

When a service or shared worker’s subresource load received a Clear-Site-Data header, the deletion was handled through the per-worker URLLoaderNetworkServiceObserver binding, whose SharedOrServiceWorkerContext had no NavigationOrDocumentHandle. In StoragePartitionImpl::CalculateStorageKey(), the pre-patch code fetched current_context().navigation_or_document(), which was null for the worker context, so the function returned std::nullopt. A null storage key caused the resulting BrowsingDataFilterBuilder to match the response origin’s first-party partition plus every partition that has that origin as its top-level site, deleting data far beyond the worker’s own partition — a partition-isolation / access-control failure. The equivalent document-initiated fetch was already narrowed via RenderFrameHostImpl::CalculateStorageKey().

The fix plumbs the worker’s blink::StorageKey through CreateURLLoaderNetworkObserverForServiceOrSharedWorker() into a new storage_key field on SharedOrServiceWorkerContext, and CalculateStorageKey() now detects the worker context via std::get_if<SharedOrServiceWorkerContext> and derives the response origin’s key with storage_key->WithOrigin(origin) (or CreateWithNonce when a nonce is present), scoping deletions to the worker’s own top-level-site partition.

Key insight
The worker Clear-Site-Data path had no storage key, so CalculateStorageKey() returned nullopt and the deletion filter over-matched across partitions; the fix carries the worker’s blink::StorageKey into the network context and uses WithOrigin() to scope deletions to the worker’s partition.

Attack Path

  1. Establish a worker A page under the attacker’s control (or one embedding the target as a worker’s top-level site) runs a service or shared worker that issues subresource fetches.
  2. Serve `Clear-Site-Data` The worker fetches a resource whose response carries a Clear-Site-Data header (e.g. "storage" or "cache"), reaching the per-worker URLLoaderNetworkServiceObserver binding.
  3. Null storage key Pre-patch CalculateStorageKey() finds no NavigationOrDocumentHandle and returns std::nullopt, so the BrowsingDataFilterBuilder is built without partition scoping.
  4. Cross-partition deletion The deletion matches the response origin’s first-party partition and every partition where that origin is the top-level site, clearing storage/cache in partitions other than the worker’s own, breaching third-party partition isolation.

Impact Assessment

An attacker gains the ability to delete another partition’s site data via a worker-initiated Clear-Site-Data response, affecting browser-side storage state beyond the worker’s own partition, with the precondition of running a service/shared worker that fetches a Clear-Site-Data-bearing response. The advisory classifies this as improper access control in Workers; no exploited-in-the-wild status is stated, and the impact is an integrity/availability issue on partitioned storage rather than a memory-safety escalation.

Changed Functions

FunctionChangeNotes
if
content/browser/storage_partition_impl.cc
modified
if
content/test/data/browsing_data/worker.js
modified

Files Changed

  • content/browser/worker_host/shared_worker_host.cc
  • content/browser/storage_partition_impl.h
  • content/browser/devtools/service_worker_devtools_agent_host.cc
  • content/browser/storage_partition_impl.cc
  • content/test/data/browsing_data/worker.js
  • content/browser/browsing_data/clear_site_data_handler_browsertest.cc
  • content/browser/service_worker/embedded_worker_instance.cc

Audit Directions

  • Missing storage key in loader contexts
    Audit other URLLoaderNetworkContext variants and observer bindings that lack a NavigationOrDocumentHandle for code paths where CalculateStorageKey() silently falls back to nullopt and thus to an over-broad filter.
  • Worker vs. document parity
    Compare worker-initiated request handling against the document-initiated equivalents (RenderFrameHostImpl::CalculateStorageKey()) to find security decisions the worker path omits.
  • `BrowsingDataFilterBuilder` scoping
    Review all builders of browsing-data deletion filters for cases where a null/absent key results in matching first-party plus all top-level-site partitions rather than a single partition.
From cd4f000a79833453cf3333762f9caa74c855e485 Mon Sep 17 00:00:00 2001
From: Ming-Ying Chung <mych@chromium.org>
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 <msramek@chromium.org>
Commit-Queue: Ming-Ying Chung <mych@chromium.org>
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Reviewed-by: Mingyu Lei <leimy@chromium.org>
Reviewed-by: Ben Kelly <wanderview@meta.com>
Cr-Commit-Position: refs/heads/main@{#1682035}
---
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 =
-      url_loader_network_observers_.current_context().navigation_or_document();
+  const URLLoaderNetworkContext& context =
+      url_loader_network_observers_.current_context();
+  if (const auto* worker_context =
+          std::get_if<URLLoaderNetworkContext::SharedOrServiceWorkerContext>(
+              &context.context())) {
+    if (worker_context->storage_key.has_value()) {
+      if (nonce) {
+        return blink::StorageKey::CreateWithNonce(origin, *nonce);
+      }
+      return worker_context->storage_key->WithOrigin(origin);
+    }
+    return std::nullopt;
+  }
+
+  NavigationOrDocumentHandle* handle = context.navigation_or_document();
   if (!handle) {
     return std::nullopt;
   }
@@ -3792,8 +3806,10 @@
 
 StoragePartitionImpl::URLLoaderNetworkContext::URLLoaderNetworkContext(
     const network::OriginatingProcessId& process_id,
-    const url::Origin& worker_origin)
-    : context_(SharedOrServiceWorkerContext{process_id, worker_origin}) {}
+    const url::Origin& worker_origin,
+    const std::optional<blink::StorageKey>& storage_key)
+    : context_(SharedOrServiceWorkerContext{process_id, worker_origin,
+                                            storage_key}) {}
 
 StoragePartitionImpl::URLLoaderNetworkContext::URLLoaderNetworkContext(
     const URLLoaderNetworkContext& other) = default;
@@ -3823,9 +3839,10 @@
 StoragePartitionImpl::URLLoaderNetworkContext
 StoragePartitionImpl::URLLoaderNetworkContext::CreateForServiceOrSharedWorker(
     const network::OriginatingProcessId& process_id,
-    const url::Origin& worker_origin) {
-  return StoragePartitionImpl::URLLoaderNetworkContext(process_id,
-                                                       worker_origin);
+    const url::Origin& worker_origin,
+    const std::optional<blink::StorageKey>& storage_key) {
+  return StoragePartitionImpl::URLLoaderNetworkContext(
+      process_id, worker_origin, storage_key);
 }
 
 StoragePartitionImpl::URLLoaderNetworkContext
diff --git a/content/browser/storage_partition_impl.h b/content/browser/storage_partition_impl.h
index b10b52c..125c67c1 100644
--- a/content/browser/storage_partition_impl.h
+++ b/content/browser/storage_partition_impl.h
@@ -445,7 +445,8 @@
   mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
   CreateURLLoaderNetworkObserverForServiceOrSharedWorker(
       const network::OriginatingProcessId& process_id,
-      const url::Origin& worker_origin);
+      const url::Origin& worker_origin,
+      const std::optional<blink::StorageKey>& storage_key = std::nullopt);
 
   mojo::PendingRemote<network::mojom::URLLoaderNetworkServiceObserver>
   CreateURLLoaderNetworkObserverForDeviceBoundSessions();
@@ -538,7 +539,14 @@
     struct SharedOrServiceWorkerContext {
       network::OriginatingProcessId process_id;
       std::optional<url::Origin> worker_origin;
+      // Holds the storage key of the worker that owns this network context.
+      // When present, `CalculateStorageKey()` uses this key to scope
+      // Clear-Site-Data filter deletions to the worker's top-level site
+      // partition. Is `std::nullopt` for contexts that lack a worker storage
+      // key.
+      std::optional<blink::StorageKey> storage_key;
     };
+
     struct DeviceBoundSessionContext {};
 
     using Context = std::variant<NavigationRequestContext,
@@ -565,7 +573,8 @@
     static StoragePartitionImpl::URLLoaderNetworkContext
     CreateForServiceOrSharedWorker(
         const network::OriginatingProcessId& process_id,
-        const url::Origin& worker_origin);
+        const url::Origin& worker_origin,
+        const std::optional<blink::StorageKey>& storage_key = std::nullopt);
 
     // Creates a URLLoaderNetworkContext for background Device Bound Sessions
     // requests.
@@ -598,8 +607,10 @@
         GlobalRenderFrameHostId global_render_frame_host_id);
 
     // Used when `context_` holds `SharedOrServiceWorkerContext`.
-    URLLoaderNetworkContext(const network::OriginatingProcessId& process_id,
-                            const url::Origin& worker_origin);
+    URLLoaderNetworkContext(
+        const network::OriginatingProcessId& process_id,
+        const url::Origin& worker_origin,
+        const std::optional<blink::StorageKey>& storage_key = std::nullopt);
 
     // Used when `context_` holds `NavigationRequestContext`.
     explicit URLLoaderNetworkContext(NavigationRequest& navigation_request);
diff --git a/content/browser/worker_host/shared_worker_host.cc b/content/browser/worker_host/shared_worker_host.cc
index 1a8633e..732a33d 100644
--- a/content/browser/worker_host/shared_worker_host.cc
+++ b/content/browser/worker_host/shared_worker_host.cc
@@ -549,7 +549,7 @@
           GetStoragePartitionImpl()
               ->CreateURLLoaderNetworkObserverForServiceOrSharedWorker(
                   ToOriginatingProcessId(GetProcessHost()->GetID()),
-                  origin_lock),
+                  origin_lock, GetWorkerStorageKey()),
           /*devtools_observer=*/mojo::NullRemote(),
           mojo::Clone(worker_client_security_state_), network_restrictions_id_,
           /*debug_tag=*/
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…

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

reported by vm...@google.com

Clear-Site-Data scope bypass in Service/Shared Workers due to missing StorageKey

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: Subresource fetches initiated by Service Workers or Shared Workers that receive a Clear-Site-Data header fail to correctly calculate a StorageKey in the browser process. This causes the resulting data deletion to fall back to an over-broad scope that impacts the target origin’s first-party storage rather than its partitioned storage. This issue potentially allows cross-origin data destruction and a bypass of storage partitioning boundaries.

Affected files:

  • content/browser/storage_partition_impl.cc
  • content/browser/browsing_data/clear_site_data_utils.cc
  • content/browser/browsing_data/browsing_data_filter_builder_impl.cc
  • third_party/blink/common/storage_key/storage_key.cc
  • content/browser/service_worker/embedded_worker_instance.cc
  • content/browser/worker_host/shared_worker_host.cc
  • services/network/network_service_network_delegate.cc
  • content/browser/browsing_data/clear_site_data_handler.cc

Estimated timestamp from git blame: 2022-09-13

Summary

A potential logic error exists in how Clear-Site-Data response headers are handled for subresource fetches initiated by Service Workers or Shared Workers. When such a fetch receives a Clear-Site-Data header, the browser process fails to associate the correct partitioned StorageKey with the request. This leads to a deletion scope that encompasses the responding origin’s first-party data, rather than being confined to the third-party partition.

Root Cause Analysis

When a network request receives a Clear-Site-Data header, the network service notifies the browser via URLLoaderNetworkServiceObserver::OnClearSiteData. The browser implementation in StoragePartitionImpl::OnClearSiteData calls CalculateStorageKey to determine the scope of deletion.

In content/browser/storage_partition_impl.cc:

std::optional<blink::StorageKey> StoragePartitionImpl::CalculateStorageKey(
    const url::Origin& origin,
    const base::UnguessableToken* nonce) {
  ...
  NavigationOrDocumentHandle* handle =
      url_loader_network_observers_.current_context().navigation_or_document();
  if (!handle) {
    return std::nullopt;
  }
  ...
}

For Service Workers and Shared Workers, the URLLoaderNetworkContext is initialized without a NavigationOrDocumentHandle (lines 3873-3878). Consequently, CalculateStorageKey returns std::nullopt.

When this std::nullopt is passed to the deletion logic (SiteDataClearer), it defaults to using BrowsingDataFilterBuilder::OriginMatchingMode::kThirdPartiesIncluded. Under this mode, blink::StorageKey::MatchesOriginForTrustedStorageDeletion returns true for the origin’s first-party storage keys (where origin and top-level site match), but false for the actual partitioned storage key where the fetch occurred. This results in the victim’s first-party data being wiped while the third-party partitioned data remains.

Potential Impact

An attacker-controlled site could potentially use a Service Worker to perform a credentialed fetch to a victim site’s logout endpoint (which typically serves Clear-Site-Data: "storage"). This would trigger the browser to wipe the victim’s first-party LocalStorage, IndexedDB, and Service Worker registrations, effectively performing a cross-origin state reset.

Suggested Reproduction Steps

Note: These steps are based on code analysis; our current environment does not support executing a live Proof of Concept.

  1. Set up a victim site https://victim.com/logout that responds with Clear-Site-Data: "storage".
  2. Ensure the user has active first-party state (e.g., LocalStorage) on https://victim.com.
  3. The user visits https://attacker.com, which registers a Service Worker.
  4. The attacker’s Service Worker executes: fetch('https://victim.com/logout', {mode: 'no-cors', credentials: 'include'}).
  5. Observe if the first-party data for victim.com is cleared, while the partitioned data for (victim.com, top=attacker.com) persists.

Suggested Fix

Modify URLLoaderNetworkContext in content/browser/storage_partition_impl.h to store the blink::StorageKey of the worker when initialized via the kSharedOrServiceWorkerContext constructor. Update StoragePartitionImpl::CalculateStorageKey to use this stored key if NavigationOrDocumentHandle is unavailable.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker