CVE-2026-79201
Overview
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
StorageKeyused to build theBrowsingDataFilterBuilderfor aClear-Site-Datadeletion. - `URLLoaderNetworkContext` / `SharedOrServiceWorkerContext`
- the per-request network-observer context; the worker variant previously carried only a
process_idandworker_origin, with noNavigationOrDocumentHandle.
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.
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
- 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.
- Serve `Clear-Site-Data`
The worker fetches a resource whose response carries a
Clear-Site-Dataheader (e.g."storage"or"cache"), reaching the per-workerURLLoaderNetworkServiceObserverbinding. - Null storage key
Pre-patch
CalculateStorageKey()finds noNavigationOrDocumentHandleand returnsstd::nullopt, so theBrowsingDataFilterBuilderis built without partition scoping. - 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
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
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/storage_partition_impl.cc |
modified | |
ifcontent/test/data/browsing_data/worker.js |
modified |
Files Changed
content/browser/worker_host/shared_worker_host.cccontent/browser/storage_partition_impl.hcontent/browser/devtools/service_worker_devtools_agent_host.cccontent/browser/storage_partition_impl.cccontent/test/data/browsing_data/worker.jscontent/browser/browsing_data/clear_site_data_handler_browsertest.cccontent/browser/service_worker/embedded_worker_instance.cc
Audit Directions
- Missing storage key in loader contextsAudit other
URLLoaderNetworkContextvariants and observer bindings that lack aNavigationOrDocumentHandlefor code paths whereCalculateStorageKey()silently falls back tonulloptand thus to an over-broad filter. - Worker vs. document parityCompare worker-initiated request handling against the document-initiated equivalents (
RenderFrameHostImpl::CalculateStorageKey()) to find security decisions the worker path omits. - `BrowsingDataFilterBuilder` scopingReview 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.
Patch
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()});
+ }
+ }
+ })());
+ }
+});
Regression Test / PoC
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()});
+ }
+ }
+ })());
+ }
+});
Original Bug Report
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.cccontent/browser/browsing_data/clear_site_data_utils.cccontent/browser/browsing_data/browsing_data_filter_builder_impl.ccthird_party/blink/common/storage_key/storage_key.cccontent/browser/service_worker/embedded_worker_instance.cccontent/browser/worker_host/shared_worker_host.ccservices/network/network_service_network_delegate.cccontent/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.
- Set up a victim site
https://victim.com/logoutthat responds withClear-Site-Data: "storage". - Ensure the user has active first-party state (e.g., LocalStorage) on
https://victim.com. - The user visits
https://attacker.com, which registers a Service Worker. - The attacker’s Service Worker executes:
fetch('https://victim.com/logout', {mode: 'no-cors', credentials: 'include'}). - Observe if the first-party data for
victim.comis 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.