Medium watch ~ plausible CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in ServiceWorker
DescriptionIncorrect authorization in ServiceWorker
ComponentServiceWorker
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — upheld by independent review by AI
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.
Independent review by AIPlausible
Reviewer notes: Claim that fix routes every storage type through Mojo is contradicted: ServiceWorker async kFileSystem (OPFS) and default cases still post true without IPC. Advisory does state severity (medium, CVSS 4.3). Diff truncated.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker503624894
Fix commitae8261c0b4f7 (chromium/src) +351/-57
CWECWE-863
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-04-17 (per vendor advisory)
Fix landed2026-08-17 (public git)
Disclosed2026-08-25
Patch gap8 days — fix public in source before the advisory

Background

`WebContentSettingsClient`
the Blink interface a worker uses to ask whether a storage API is permitted, exposing both a synchronous AllowStorageAccessSync and an asynchronous AllowStorageAccess(storage_type, callback).
`WorkerContentSettingsProxy` (Mojo)
the IPC interface (AllowIndexedDB, AllowCacheStorage, AllowWebLocks, RequestFileSystemAccessSync) over which the renderer worker asks the browser process to evaluate per-origin content settings.
Content settings
browser-side, per-origin policy (e.g. blocking site data / storage for a given origin) that determines whether IndexedDB, CacheStorage, WebLocks, and OPFS are allowed.
Storage key origin
the origin that keys storage and content-settings decisions for a worker, obtained via owner->GetWorkerStorageKey().origin().

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Claim that fix routes every storage type through Mojo is contradicted: ServiceWorker async kFileSystem (OPFS) and default cases still post true without IPC. Advisory does state severity (medium, CVSS 4.3). Diff truncated.

ServiceWorkerContentSettingsProxy and SharedWorkerContentSettingsProxy overrode only the synchronous AllowStorageAccessSync and inherited the base WebContentSettingsClient::AllowStorageAccess for asynchronous queries, which returns access as allowed without ever consulting the browser process (inferred from the CL replacing that default with real IPC routing). Consequently, asynchronous storage-access checks for kIndexedDB, kCacheStorage, kWebLocks — and OPFS (kFileSystem) queries that Service Workers issue asynchronously — bypassed the browser’s content-settings evaluation entirely and were silently granted. In ServiceWorkerContentSettingsProxy::AllowStorageAccessSync the old code additionally routed only kIndexedDB through IPC and fell through to return true for kCacheStorage/kWebLocks, so even the synchronous path failed to authorize those two APIs. Separately, SharedWorkerContentSettingsProxyImpl keyed its checks on url::Origin::Create(script_url) rather than the worker’s real storage-key origin, so decisions could be evaluated against the wrong origin.

The fix implements AllowStorageAccess in both proxies to route every storage type through the Mojo proxy, adds the missing synchronous kCacheStorage/kWebLocks routings, and switches the shared-worker proxy origin to owner->GetWorkerStorageKey().origin().

Key insight
Relying on the permissive base-class default for the asynchronous AllowStorageAccess override (and never routing several storage types through IPC) let workers reach storage APIs without the browser’s content-settings authorization; the fix makes every storage query — sync and async — go through the browser process and uses the correct storage-key origin.

Attack Path

  1. Establish a worker under restrictive settings In a context where the user’s content settings block site data/storage for the origin, register or run a Service Worker or Shared Worker.
  2. Invoke an async storage API From the worker, call an asynchronous storage API (indexedDB.open, caches/CacheStorage, navigator.locks, or OPFS via kFileSystem).
  3. Async check bypasses the browser Blink invokes the inherited default AllowStorageAccess, which returns “allowed” without any Mojo round-trip to the browser’s content-settings logic (inferred).
  4. Unauthorized access granted The worker obtains storage access the content settings were meant to deny, i.e. incorrect authorization; the pre-patch synchronous path similarly mis-authorizes kCacheStorage/kWebLocks in Service Workers.

Impact Assessment

An attacker-controlled page/worker gains access to storage APIs (IndexedDB, CacheStorage, WebLocks, OPFS) in the renderer context despite content settings configured to block them, defeating the intended per-origin storage policy. The precondition is a content-settings configuration that should block storage for the origin together with a Service or Shared Worker issuing the queries. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3.

Changed Functions

FunctionChangeNotes
switch
third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
modified
GetService
third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
modified
SharedWorkerContentSettingsProxy
third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
modified
CORE_EXPORT
third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
modified
if
third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
modified
switch
third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
modified
GetService
third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
modified
ServiceWorkerContentSettingsProxy
third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
modified
MODULES_EXPORT
third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
modified

Files Changed

  • third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
  • third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
  • third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom
  • third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc
  • content/browser/worker_host/shared_worker_content_settings_proxy_impl.cc
  • third_party/blink/web_tests/external/wpt/IndexedDB/idbfactory_open.any.js
  • third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
  • third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
  • third_party/blink/renderer/modules/BUILD.gn

Audit Directions

  • Unoverridden async permission methods
    Audit every WebContentSettingsClient subclass (and similar permission-proxy interfaces) for overriding only the sync variant while inheriting a default-allow async method.
  • Origin selection for content settings
    Check other proxies/hosts that derive an origin from a script URL (url::Origin::Create(script_url)) rather than the authoritative storage key.
  • Incomplete storage-type switches
    Look for switch/if chains over StorageType that route only some types through IPC and return true (allow) for the rest.
From ae8261c0b4f7a8527e3565b6ee72f63413c14bc6 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <[email protected]>
Date: Sun, 16 Aug 2026 23:37:29 -0700
Subject: [PATCH] Implement async AllowStorageAccess in Service and Shared Worker content settings proxies

Previously, ServiceWorkerContentSettingsProxy and
SharedWorkerContentSettingsProxy only overrode the synchronous
AllowStorageAccessSync method from WebContentSettingsClient, relying on
the base default implementation for asynchronous queries. Additionally,
synchronous checks for CacheStorage and WebLocks were not routed through
the IPC proxy in ServiceWorkerContentSettingsProxy.

This CL implements the asynchronous AllowStorageAccess method in both
proxy classes to route IndexedDB, CacheStorage, and WebLocks queries to
the browser process via Mojo IPC. This ensures consistent and accurate
content settings evaluation across both synchronous and asynchronous
APIs. It also adds the missing synchronous IPC routings for CacheStorage
and WebLocks in ServiceWorkerContentSettingsProxy.

A unit test is added to verify that storage access queries in
ServiceWorkerContentSettingsProxy correctly propagate responses from
the browser process.

TAG=agy
CONV=afbe35fe-485c-4ef6-853f-1874ce409f0e

Bug: 503624894
Change-Id: I4d9e862a2b88c71f628afba61ab706d0180c1e4d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8062387
Reviewed-by: Hiroki Nakagawa <[email protected]>
Commit-Queue: Yoshisato Yanagisawa <[email protected]>
Reviewed-by: Hidehiko Abe <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1680367}
---
diff --git a/content/browser/worker_host/shared_worker_content_settings_proxy_impl.cc b/content/browser/worker_host/shared_worker_content_settings_proxy_impl.cc
index 3750cf8e..5e5c8b6 100644
--- a/content/browser/worker_host/shared_worker_content_settings_proxy_impl.cc
+++ b/content/browser/worker_host/shared_worker_content_settings_proxy_impl.cc
@@ -16,7 +16,7 @@
     const GURL& script_url,
     SharedWorkerHost* owner,
     mojo::PendingReceiver<blink::mojom::WorkerContentSettingsProxy> receiver)
-    : origin_(url::Origin::Create(script_url)),
+    : origin_(owner->GetWorkerStorageKey().origin()),
       owner_(owner),
       receiver_(this, std::move(receiver)) {}
 
diff --git a/third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom b/third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom
index 98899298..17a3e82 100644
--- a/third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom
+++ b/third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom
@@ -24,6 +24,7 @@
   AllowWebLocks() => (bool result);
 
   // Returns whether the worker is allowed access to the file system.
+  // TODO(crbug.com/503624894): Rename to AllowFileSystem in a follow-up.
   [Sync]
   RequestFileSystemAccessSync() => (bool result);
 };
diff --git a/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc b/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
index db16874..db8efa0 100644
--- a/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
+++ b/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.cc
@@ -8,18 +8,53 @@
 #include <utility>
 
 #include "base/metrics/histogram_macros.h"
+#include "base/task/single_thread_task_runner.h"
 #include "third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom-blink.h"
-#include "third_party/blink/renderer/platform/wtf/thread_specific.h"
+#include "third_party/blink/renderer/platform/wtf/functional.h"
 
 namespace blink {
 
 SharedWorkerContentSettingsProxy::SharedWorkerContentSettingsProxy(
     mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> host_info)
-    : host_info_(std::move(host_info)) {}
-SharedWorkerContentSettingsProxy::~SharedWorkerContentSettingsProxy() = default;
+    : host_info_(std::move(host_info)) {
+  DETACH_FROM_THREAD(worker_thread_checker_);
+}
+
+SharedWorkerContentSettingsProxy::~SharedWorkerContentSettingsProxy() {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+}
+
+void SharedWorkerContentSettingsProxy::AllowStorageAccess(
+    StorageType storage_type,
+    base::OnceCallback<void(bool)> callback) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  switch (storage_type) {
+    case StorageType::kIndexedDB:
+      GetService()->AllowIndexedDB(std::move(callback));
+      return;
+    case StorageType::kCacheStorage:
+      GetService()->AllowCacheStorage(std::move(callback));
+      return;
+    case StorageType::kWebLocks:
+      GetService()->AllowWebLocks(std::move(callback));
+      return;
+    case StorageType::kFileSystem:
+      // TODO(crbug.com/503624894): Rename RequestFileSystemAccessSync to
+      // AllowFileSystem in a follow-up.
+      GetService()->RequestFileSystemAccessSync(std::move(callback));
+      return;
+    default:
+      // TODO(crbug.com/40103756): Revisit this default in the future.
+      base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+          FROM_HERE, blink::BindOnce(std::move(callback), true));
+      return;
+  }
+}
 
 bool SharedWorkerContentSettingsProxy::AllowStorageAccessSync(
     StorageType storage_type) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  // TODO(crbug.com/503624894): Remove obsolete UMA histograms in a follow-up.
   bool result = false;
   switch (storage_type) {
     case StorageType::kIndexedDB: {
@@ -43,7 +78,7 @@
       break;
     }
     default: {
-      // TODO([email protected]): Revisit this default in the future.
+      // TODO(crbug.com/40103756): Revisit this default in the future.
       return true;
     }
   }
@@ -51,19 +86,14 @@
   return result;
 }
 
-// Use ThreadSpecific to ensure that |content_settings_instance_host| is
-// destructed on worker thread.
-// Each worker has a dedicated thread so this is safe.
 mojo::Remote<mojom::blink::WorkerContentSettingsProxy>&
 SharedWorkerContentSettingsProxy::GetService() {
-  DEFINE_THREAD_SAFE_STATIC_LOCAL(
-      ThreadSpecific<mojo::Remote<mojom::blink::WorkerContentSettingsProxy>>,
-      content_settings_instance_host, ());
-  if (!content_settings_instance_host.IsSet()) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  if (!host_remote_.is_bound()) {
     DCHECK(host_info_.is_valid());
-    content_settings_instance_host->Bind(std::move(host_info_));
+    host_remote_.Bind(std::move(host_info_));
   }
-  return *content_settings_instance_host;
+  return host_remote_;
 }
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h b/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
index 90b8b53..9e0ad03 100644
--- a/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
+++ b/third_party/blink/renderer/core/workers/shared_worker_content_settings_proxy.h
@@ -5,36 +5,42 @@
 #ifndef THIRD_PARTY_BLINK_RENDERER_CORE_WORKERS_SHARED_WORKER_CONTENT_SETTINGS_PROXY_H_
 #define THIRD_PARTY_BLINK_RENDERER_CORE_WORKERS_SHARED_WORKER_CONTENT_SETTINGS_PROXY_H_
 
+#include "base/threading/thread_checker.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/remote.h"
-#include "third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom-blink-forward.h"
+#include "third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom-blink.h"
 #include "third_party/blink/public/platform/web_content_settings_client.h"
 #include "third_party/blink/public/platform/web_security_origin.h"
+#include "third_party/blink/renderer/core/core_export.h"
 #include "third_party/blink/renderer/platform/weborigin/security_origin.h"
 
 namespace blink {
 
 // SharedWorkerContentSettingsProxy provides content settings information.
-// This is created on the main thread and then called on the worker thread.
-// Information is requested via a Mojo connection to the browser process.
-class SharedWorkerContentSettingsProxy : public WebContentSettingsClient {
+// This is created on the main thread, passed to the worker thread via
+// GlobalScopeCreationParams, and then called and destroyed on the worker
+// thread. Information is requested via a Mojo connection to the browser
+// process.
+class CORE_EXPORT SharedWorkerContentSettingsProxy
+    : public WebContentSettingsClient {
  public:
   SharedWorkerContentSettingsProxy(
       mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> host_info);
   ~SharedWorkerContentSettingsProxy() override;
 
   // WebContentSettingsClient overrides.
+  void AllowStorageAccess(StorageType storage_type,
+                          base::OnceCallback<void(bool)> callback) override;
   bool AllowStorageAccessSync(StorageType storage_type) override;
 
  private:
-  // To ensure the returned pointer is destructed on the same thread
-  // that it was constructed on, this uses ThreadSpecific.
   mojo::Remote<mojom::blink::WorkerContentSettingsProxy>& GetService();
 
-  // This is set on the main thread at the ctor,
-  // and moved to thread local storage on the worker thread
-  // when GetService() is called for the first time.
   mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> host_info_;
+  // Bound and destroyed on the worker thread.
+  mojo::Remote<mojom::blink::WorkerContentSettingsProxy> host_remote_;
+
+  THREAD_CHECKER(worker_thread_checker_);
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
index d19eed0..1d3e6877 100644
--- a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
+++ b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.cc
@@ -5,47 +5,93 @@
 #include "third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h"
 
 #include <memory>
+#include <utility>
 
 #include "base/metrics/histogram_macros.h"
+#include "base/notreached.h"
+#include "base/task/single_thread_task_runner.h"
+#include "third_party/blink/renderer/platform/wtf/functional.h"
 #include "third_party/blink/renderer/platform/wtf/thread_specific.h"
 
 namespace blink {
 
 ServiceWorkerContentSettingsProxy::ServiceWorkerContentSettingsProxy(
     mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> host_info)
-    : host_info_(std::move(host_info)) {}
+    : host_info_(std::move(host_info)) {
+  DETACH_FROM_THREAD(worker_thread_checker_);
+}
 
-ServiceWorkerContentSettingsProxy::~ServiceWorkerContentSettingsProxy() =
-    default;
+ServiceWorkerContentSettingsProxy::~ServiceWorkerContentSettingsProxy() {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+}
 
-bool ServiceWorkerContentSettingsProxy::AllowStorageAccessSync(
-    StorageType storage_type) {
-  bool result = false;
-  if (storage_type == StorageType::kIndexedDB) {
-    SCOPED_UMA_HISTOGRAM_TIMER("ServiceWorker.AllowIndexedDBTime");
-    GetService()->AllowIndexedDB(&result);
-    return result;
-  } else if (storage_type == StorageType::kFileSystem) {
-    NOTREACHED();
-  } else {
-    // TODO([email protected]): Revisit this default in the future.
-    return true;
+void ServiceWorkerContentSettingsProxy::AllowStorageAccess(
+    StorageType storage_type,
+    base::OnceCallback<void(bool)> callback) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  switch (storage_type) {
+    case StorageType::kIndexedDB:
+      GetService()->AllowIndexedDB(std::move(callback));
+      return;
+    case StorageType::kCacheStorage:
+      GetService()->AllowCacheStorage(std::move(callback));
+      return;
+    case StorageType::kWebLocks:
+      GetService()->AllowWebLocks(std::move(callback));
+      return;
+    case StorageType::kFileSystem:
+      // OPFS (Origin Private File System) calls AllowStorageAccess(kFileSystem)
+      // asynchronously in ServiceWorkers.
+      [[fallthrough]];
+    default:
+      // TODO(crbug.com/40103756): Revisit this default in the future.
+      base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+          FROM_HERE, blink::BindOnce(std::move(callback), true));
+      return;
   }
 }
 
-// Use ThreadSpecific to ensure that |content_settings_instance_host| is
-// destructed on worker thread.
-// Each worker has a dedicated thread so this is safe.
+bool ServiceWorkerContentSettingsProxy::AllowStorageAccessSync(
+    StorageType storage_type) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  // TODO(crbug.com/503624894): Remove obsolete UMA histograms in a follow-up.
+  bool result = false;
+  switch (storage_type) {
+    case StorageType::kIndexedDB: {
+      SCOPED_UMA_HISTOGRAM_TIMER("ServiceWorker.AllowIndexedDBTime");
+      GetService()->AllowIndexedDB(&result);
+      break;
+    }
+    case StorageType::kCacheStorage: {
+      SCOPED_UMA_HISTOGRAM_TIMER("ServiceWorker.AllowCacheStorageTime");
+      GetService()->AllowCacheStorage(&result);
+      break;
+    }
+    case StorageType::kWebLocks: {
+      SCOPED_UMA_HISTOGRAM_TIMER("ServiceWorker.AllowWebLocksTime");
+      GetService()->AllowWebLocks(&result);
+      break;
+    }
+    case StorageType::kFileSystem:
+      // Legacy synchronous FileSystem API is not exposed to ServiceWorkers.
+      NOTREACHED();
+    default: {
+      // TODO(crbug.com/40103756): Revisit this default in the future.
+      return true;
+    }
+  }
+
+  return result;
+}
+
 mojo::Remote<mojom::blink::WorkerContentSettingsProxy>&
 ServiceWorkerContentSettingsProxy::GetService() {
-  DEFINE_THREAD_SAFE_STATIC_LOCAL(
-      ThreadSpecific<mojo::Remote<mojom::blink::WorkerContentSettingsProxy>>,
-      content_settings_instance_host, ());
-  if (!content_settings_instance_host.IsSet()) {
+  DCHECK_CALLED_ON_VALID_THREAD(worker_thread_checker_);
+  if (!host_remote_.is_bound()) {
     DCHECK(host_info_.is_valid());
-    content_settings_instance_host->Bind(std::move(host_info_));
+    host_remote_.Bind(std::move(host_info_));
   }
-  return *content_settings_instance_host;
+  return host_remote_;
 }
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
index 65730a8..cf084f0 100644
--- a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
+++ b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h
@@ -5,20 +5,23 @@
 #ifndef THIRD_PARTY_BLINK_RENDERER_MODULES_SERVICE_WORKER_SERVICE_WORKER_CONTENT_SETTINGS_PROXY_H_
 #define THIRD_PARTY_BLINK_RENDERER_MODULES_SERVICE_WORKER_SERVICE_WORKER_CONTENT_SETTINGS_PROXY_H_
 
+#include "base/threading/thread_checker.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/remote.h"
 #include "third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom-blink.h"
 #include "third_party/blink/public/platform/web_content_settings_client.h"
 #include "third_party/blink/public/platform/web_security_origin.h"
+#include "third_party/blink/renderer/modules/modules_export.h"
 #include "third_party/blink/renderer/platform/weborigin/security_origin.h"
 
 namespace blink {
 
-// Provides the content settings information from browser process.  This proxy
+// Provides the content settings information from browser process. This proxy
 // is created by WebEmbeddedWorkerImpl::StartWorkerContext() on a background
-// ThreadPool thread, and destroyed on that thread. But all methods are called
-// on the service worker thread.
-class ServiceWorkerContentSettingsProxy final
+// ThreadPool thread, passed to the service worker thread via
+// GlobalScopeCreationParams, and then called and destroyed on the service
+// worker thread.
+class MODULES_EXPORT ServiceWorkerContentSettingsProxy final
     : public blink::WebContentSettingsClient {
  public:
   explicit ServiceWorkerContentSettingsProxy(
@@ -35,18 +38,21 @@
 
   // WebContentSettingsClient overrides.
   // Asks the browser process about the settings.
+  void AllowStorageAccess(StorageType storage_type,
+                          base::OnceCallback<void(bool)> callback) override;
+
+  // Asks the browser process about the settings.
   // Blocks until the response arrives.
   bool AllowStorageAccessSync(StorageType storage_type) override;
 
  private:
-  // To ensure the returned pointer is destructed on the same thread
-  // that it was constructed on, this uses ThreadSpecific.
   mojo::Remote<mojom::blink::WorkerContentSettingsProxy>& GetService();
 
-  // This is set on the ThreadPool thread at the ctor, and moved to thread
-  // local storage on the service worker thread when GetService() is called for
-  // the first time.
   mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> host_info_;
+  // Bound and destroyed on the service worker thread.
+  mojo::Remote<mojom::blink::WorkerContentSettingsProxy> host_remote_;
+
+  THREAD_CHECKER(worker_thread_checker_);
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc
new file mode 100644
index 0000000..2b11c403
--- /dev/null
+++ b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc
@@ -0,0 +1,204 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc
new file mode 100644
index 0000000..2b11c403
--- /dev/null
+++ b/third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy_test.cc
@@ -0,0 +1,204 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "third_party/blink/renderer/modules/service_worker/service_worker_content_settings_proxy.h"
+
+#include <memory>
+#include <utility>
+
+#include "base/functional/bind.h"
+#include "base/run_loop.h"
+#include "base/task/bind_post_task.h"
+#include "base/task/single_thread_task_runner.h"
+#include "mojo/public/cpp/bindings/pending_receiver.h"
+#include "mojo/public/cpp/bindings/receiver.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/worker/worker_content_settings_proxy.mojom-blink.h"
+#include "third_party/blink/renderer/platform/scheduler/public/non_main_thread.h"
+#include "third_party/blink/renderer/platform/scheduler/public/thread_type.h"
+#include "third_party/blink/renderer/platform/testing/task_environment.h"
+#include "third_party/blink/renderer/platform/weborigin/security_origin.h"
+
+namespace blink {
+namespace {
+
+class MockWorkerContentSettingsProxy
+    : public mojom::blink::WorkerContentSettingsProxy {
+ public:
+  MockWorkerContentSettingsProxy() = default;
+  ~MockWorkerContentSettingsProxy() override = default;
+
+  mojo::PendingRemote<mojom::blink::WorkerContentSettingsProxy> CreateRemote() {
+    return receiver_.BindNewPipeAndPassRemote();
+  }
+
+  void set_allow_indexed_db(bool allow) { allow_indexed_db_ = allow; }
+  void set_allow_cache_storage(bool allow) { allow_cache_storage_ = allow; }
+  void set_allow_web_locks(bool allow) { allow_web_locks_ = allow; }
+
+  // mojom::blink::WorkerContentSettingsProxy implementation:
+  void AllowIndexedDB(AllowIndexedDBCallback callback) override {
+    std::move(callback).Run(allow_indexed_db_);
+  }
+  void AllowCacheStorage(AllowCacheStorageCallback callback) override {
+    std::move(callback).Run(allow_cache_storage_);
+  }
+  void AllowWebLocks(AllowWebLocksCallback callback) override {
+    std::move(callback).Run(allow_web_locks_);
+  }
+  void RequestFileSystemAccessSync(
+      RequestFileSystemAccessSyncCallback callback) override {
+    std::move(callback).Run(false);
+  }
+
+ private:
+  mojo::Receiver<mojom::blink::WorkerContentSettingsProxy> receiver_{this};
+  bool allow_indexed_db_ = true;
+  bool allow_cache_storage_ = true;
+  bool allow_web_locks_ = true;
+};
+
+// Tests for ServiceWorkerContentSettingsProxy.
+// The proxy calls ServiceWorkerContentSettingsProxy::GetService(), which stores
+// a mojo::Remote in ThreadSpecific storage. Running tests on a dedicated worker
+// thread ensures that ThreadSpecific state is cleanly destroyed when the thread
+// stops after each test, preventing state leakage across multiple tests.
+class ServiceWorkerContentSettingsProxyTest : public testing::Test {
+ protected:
+  void SetUp() override {
+    worker_thread_ = NonMainThread::CreateThread(
+        ThreadCreationParams(ThreadType::kTestThread)
+            .SetThreadNameForTest("worker_thread"));
+    mock_proxy_ = std::make_unique<MockWorkerContentSettingsProxy>();
+    proxy_ = std::make_unique<ServiceWorkerContentSettingsProxy>(
+        mock_proxy_->CreateRemote());
+  }
+
+  void TearDown() override {
+    // Delete the proxy on the worker thread, then stop the worker thread to
+    // ensure ThreadSpecific storage is cleaned up.
+    worker_thread_->GetTaskRunner()->PostTask(
+        FROM_HERE,
+        base::BindOnce(
+            [](std::unique_ptr<ServiceWorkerContentSettingsProxy>) {},
+            std::move(proxy_)));
+    worker_thread_.reset();
+    mock_proxy_.reset();
+  }
+
+  bool RunAllowStorageAccess(
+      WebContentSettingsClient::StorageType storage_type) {
+    bool result = false;
+    base::RunLoop loop;
+    worker_thread_->GetTaskRunner()->PostTask(
+        FROM_HERE,
+        base::BindOnce(
+            [](ServiceWorkerContentSettingsProxy* proxy,
+               WebContentSettingsClient::StorageType storage_type,
+               base::OnceCallback<void(bool)> callback) {
+              proxy->AllowStorageAccess(storage_type, std::move(callback));
+            },
+            proxy_.get(), storage_type,
+            base::BindPostTask(
+                base::SingleThreadTaskRunner::GetCurrentDefault(),
+                base::BindOnce(
+                    [](bool* out_result, base::OnceClosure quit_closure,
+                       bool allow) {
+                      *out_result = allow;
+                      std::move(quit_closure).Run();
+                    },
+                    &result, loop.QuitClosure()))));
+    loop.Run();
+    return result;
+  }
+
+  bool RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType storage_type) {
+    bool result = false;
+    base::RunLoop loop;
+    worker_thread_->GetTaskRunner()->PostTask(
+        FROM_HERE, base::BindOnce(
+                       [](ServiceWorkerContentSettingsProxy* proxy,
+                          WebContentSettingsClient::StorageType storage_type,
+                          base::OnceCallback<void(bool)> reply_callback) {
+                         bool allowed =
+                             proxy->AllowStorageAccessSync(storage_type);
+                         std::move(reply_callback).Run(allowed);
+                       },
+                       proxy_.get(), storage_type,
+                       base::BindPostTask(
+                           base::SingleThreadTaskRunner::GetCurrentDefault(),
+                           base::BindOnce(
+                               [](bool* out_result,
+                                  base::OnceClosure quit_closure, bool allow) {
+                                 *out_result = allow;
+                                 std::move(quit_closure).Run();
+                               },
+                               &result, loop.QuitClosure()))));
+    loop.Run();
+    return result;
+  }
+
+  test::TaskEnvironment task_environment_;
+  std::unique_ptr<NonMainThread> worker_thread_;
+  std::unique_ptr<MockWorkerContentSettingsProxy> mock_proxy_;
+  std::unique_ptr<ServiceWorkerContentSettingsProxy> proxy_;
+};
+
+TEST_F(ServiceWorkerContentSettingsProxyTest, AllowIndexedDB) {
+  // Test IndexedDB async
+  mock_proxy_->set_allow_indexed_db(false);
+  EXPECT_FALSE(
+      RunAllowStorageAccess(WebContentSettingsClient::StorageType::kIndexedDB));
+  mock_proxy_->set_allow_indexed_db(true);
+  EXPECT_TRUE(
+      RunAllowStorageAccess(WebContentSettingsClient::StorageType::kIndexedDB));
+
+  // Test IndexedDB sync
+  mock_proxy_->set_allow_indexed_db(false);
+  EXPECT_FALSE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kIndexedDB));
+  mock_proxy_->set_allow_indexed_db(true);
+  EXPECT_TRUE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kIndexedDB));
+}
+
+TEST_F(ServiceWorkerContentSettingsProxyTest, AllowCacheStorage) {
+  // Test CacheStorage async
+  mock_proxy_->set_allow_cache_storage(false);
+  EXPECT_FALSE(RunAllowStorageAccess(
+      WebContentSettingsClient::StorageType::kCacheStorage));
+  mock_proxy_->set_allow_cache_storage(true);
+  EXPECT_TRUE(RunAllowStorageAccess(
+      WebContentSettingsClient::StorageType::kCacheStorage));
+
+  // Test CacheStorage sync
+  mock_proxy_->set_allow_cache_storage(false);
+  EXPECT_FALSE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kCacheStorage));
+  mock_proxy_->set_allow_cache_storage(true);
+  EXPECT_TRUE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kCacheStorage));
+}
+
+TEST_F(ServiceWorkerContentSettingsProxyTest, AllowWebLocks) {
+  // Test WebLocks async
+  mock_proxy_->set_allow_web_locks(false);
+  EXPECT_FALSE(
+      RunAllowStorageAccess(WebContentSettingsClient::StorageType::kWebLocks));
+  mock_proxy_->set_allow_web_locks(true);
+  EXPECT_TRUE(
+      RunAllowStorageAccess(WebContentSettingsClient::StorageType::kWebLocks));
+
+  // Test WebLocks sync
+  mock_proxy_->set_allow_web_locks(false);
+  EXPECT_FALSE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kWebLocks));
+  mock_proxy_->set_allow_web_locks(true);
+  EXPECT_TRUE(RunAllowStorageAccessSync(
+      WebContentSettingsClient::StorageType::kWebLocks));
+}
+
+}  // namespace
+}  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/IndexedDB/idbfactory_open.any.js b/third_party/blink/web_tests/external/wpt/IndexedDB/idbfactory_open.any.js
index aadfc5c..aed8f18 100644
--- a/third_party/blink/web_tests/external/wpt/IndexedDB/idbfactory_open.any.js
+++ b/third_party/blink/web_tests/external/wpt/IndexedDB/idbfactory_open.any.js
@@ -192,7 +192,7 @@
 
 function should_work(val, expected_version) {
     let name = format_value(val);
-    let dbname = 'test-db-does-not-exist';
+    let dbname = 'test-db-does-not-exist-' + name;
     async_test(function (t) {
         indexedDB.deleteDatabase(dbname);
         let rq = indexedDB.open(dbname, val);
Loading diff…

Original Bug Report

reported by [email protected]

3PCD Bypass via Premature SW Ready Resolution and Storage Permission Bypass

Flapjack, 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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A series of logic flaws allow a third-party iframe to bypass third-party cookie blocking and access unpartitioned 1P storage (when partitioning is disabled). A premature resolution of navigator.serviceWorker.ready gives the 3P iframe a handle to the 1P Service Worker, while a missing async method override in the renderer’s content settings proxy completely bypasses storage permission checks for running Service Workers.

Affected files:

  • content/browser/service_worker/service_worker_controllee_request_handler.cc
  • content/browser/service_worker/service_worker_version.cc
  • components/embedder_support/content_settings_utils.cc

Estimated timestamp from git blame: 2025-05-05

Description

There are logic flaws in the Service Worker and storage permission architectures that potentially allow a malicious third-party iframe to bypass Third-Party Cookie blocking. When Third-Party Storage Partitioning is disabled (e.g., via enterprise policy or flags), this allows the 3P iframe to access unpartitioned first-party (1P) storage, enabling cross-site tracking.

The exploit relies on three distinct flaws:

Flaw 1: Premature Resolution of navigator.serviceWorker.ready When an iframe navigates to a URL controlled by a previously installed 1P Service Worker, ServiceWorkerControlleeRequestHandler::ContinueWithRegistration evaluates if the worker is allowed to control the page. It adds the registration to the client’s matching list before performing the AllowServiceWorker check:

// content/browser/service_worker/service_worker_controllee_request_handler.cc
void ServiceWorkerControlleeRequestHandler::ContinueWithRegistration(...) {
  ...
  // FLAW 1: This synchronously resolves navigator.serviceWorker.ready in the renderer
  service_worker_client_->AddMatchingRegistration(registration.get());
  
  // This evaluates to false if 3P cookies are blocked, falling back to network.
  AllowServiceWorkerResult allow_service_worker = 
      GetContentClient()->browser()->AllowServiceWorker(...);

Even though the navigation correctly falls back to the network, the renderer-side navigator.serviceWorker.ready promise has already resolved, providing the 3P iframe with a handle to the 1P ServiceWorkerRegistration.

Flaw 2: Fake 1P Contexts in Worker Start Checks The 3P iframe can use the handle to call registration.active.postMessage(). To handle the message, the browser starts the worker and calls ServiceWorkerVersion::IsStartWorkerAllowed. This function synthesizes a fake 1P top_frame_origin by passing url::Origin::Create(scope_) to AllowServiceWorker. Because the origin matches the top_frame_origin, CookieSettings is tricked into treating it as a 1P request, bypassing the 3P cookie block and allowing the worker to start.

Flaw 3: Renderer-Side Storage Permission Bypass Once the worker is running, it can call indexedDB.open(). In the renderer, IDBFactory::AllowIndexedDB checks permissions by calling the asynchronous settings_client->AllowStorageAccess().

However, the WebContentSettingsClient used by Service Workers (ServiceWorkerContentSettingsProxy) only overrides the synchronous AllowStorageAccessSync method. It fails to override the asynchronous AllowStorageAccess method. Consequently, the call falls back to the default implementation in WebContentSettingsClient:

// third_party/blink/public/platform/web_content_settings_client.h
virtual void AllowStorageAccess(..., base::OnceCallback<void(bool)> callback) {
  std::move(callback).Run(true);
}

This immediately returns true, completely bypassing the browser-side storage permission checks (such as ServiceWorkerContentSettingsProxyImpl::AllowIndexedDB). Since browser-side IndexedDB binding (BucketContext::Open) performs no secondary permission checks, the Service Worker is granted access.

Impact

An attacker can read and write 1P tracking identifiers to IndexedDB from a 3P iframe, bypassing the user’s 3P cookie blocking preferences. If Third-Party Storage Partitioning is disabled, the worker’s StorageKey behaves as if it only contains the origin, granting full access to the user’s unpartitioned 1P IndexedDB and CacheStorage.

Potential Reproduction Steps

Note: These are suggested steps based on code analysis.

  1. Disable Third-Party Storage Partitioning and block Third-Party Cookies.
  2. User visits https://attacker.com (1P). The site registers a Service Worker and writes a tracking ID to IndexedDB.
  3. User visits https://victim.com (1P). victim.com embeds <iframe src="https://attacker.com/iframe.html">.
  4. Inside the 3P iframe.html, navigator.serviceWorker.ready resolves (Flaw 1).
  5. iframe.html calls registration.active.postMessage("get_id").
  6. The Service Worker starts (Flaw 2) and calls indexedDB.open(), which is granted without browser-side checks (Flaw 3).
  7. The worker reads the 1P tracking ID and posts it back to the 3P iframe.

Suggested Fix

  1. Move the call to service_worker_client_->AddMatchingRegistration(registration.get()); in ServiceWorkerControlleeRequestHandler::ContinueWithRegistration to after the AllowServiceWorker check evaluates to true.
  2. Ensure ServiceWorkerContentSettingsProxy and SharedWorkerContentSettingsProxy correctly override the asynchronous AllowStorageAccess method to route the permission check to the browser process, or update IDBFactory and CacheStorage to use the synchronous method if appropriate for workers.
  3. Review ServiceWorkerVersion::IsStartWorkerAllowed to ensure it accurately reflects the context requesting the worker start, rather than always synthesizing a 1P context.

Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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