Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ServiceWorker
DescriptionInappropriate implementation in ServiceWorker
ComponentServiceWorker
Bug ClassLogic Error
Tracker496632973
Fix commit18de96979e31 (chromium/src) +65/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
TEST
content/browser/service_worker/service_worker_loader_helpers_unittest.cc
modified
ServiceWorkerLoaderHelpersSyntheticResponseTest
content/browser/service_worker/service_worker_loader_helpers_unittest.cc
modified

Files Changed

  • content/browser/service_worker/service_worker_loader_helpers.cc
  • content/browser/service_worker/service_worker_loader_helpers.h
  • content/browser/service_worker/service_worker_loader_helpers_unittest.cc
  • content/browser/service_worker/service_worker_registry.cc
From 18de96979e316dfabd747745c0047c32ad2c5f4a Mon Sep 17 00:00:00 2001
From: Shunya Shishido <sisidovski@chromium.org>
Date: Wed, 01 Apr 2026 02:55:59 -0700
Subject: [PATCH] Ensure consistent registration IDs for synthetic responses by StorageKey

The ServiceWorkerSyntheticResponse feature previously used a single
constant ID for all synthetic Service Worker registrations, leading to
collisions and potential cross-origin leakage.

While assigning unique IDs for each call fixes the collision, it
introduces a new issue: `ServiceWorkerClient` expects the same
registration object (and thus the same ID) for the same context.
Repeated navigations to the same origin would create multiple
registrations with different IDs, triggering `DCHECK` failures in
`ServiceWorkerClient::IsMatchingRegistration`.

This CL resolves this by: 1. Renaming `CreateSyntheticRegistration` to
`GetOrCreateSyntheticRegistration`. 2. Implementing a cache for
synthetic registration IDs keyed by `blink::StorageKey`.

Now:
- Different storage keys continue to receive unique, decremented
  registration IDs (e.g., -10, -11, -12, etc.).
- The same storage key consistently returns the same registration ID
  across multiple calls.

This ensures that synthetic registrations are distinct across origins
while remaining stable and consistent for the same origin, satisfying
the requirements of `ServiceWorkerClient`.

As we don't persist these registration IDs, and we don't have any
reserved negative numbers in under -10 range, this is OK as a temporary
solution.

Bug: 496638615,496632973
Change-Id: I783c58713a4fc3e71dcb3e2f18ac30563a2bc5b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7711412
Commit-Queue: Shunya Shishido <sisidovski@chromium.org>
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608403}
---

diff --git a/content/browser/service_worker/service_worker_loader_helpers.cc b/content/browser/service_worker/service_worker_loader_helpers.cc
index 610cf2b..7c194b4 100644
--- a/content/browser/service_worker/service_worker_loader_helpers.cc
+++ b/content/browser/service_worker/service_worker_loader_helpers.cc
@@ -9,6 +9,7 @@
 
 #include "base/byte_size.h"
 #include "base/command_line.h"
+#include "base/containers/lru_cache.h"
 #include "base/no_destructor.h"
 #include "base/strings/string_split.h"
 #include "base/strings/string_util.h"
@@ -478,8 +479,30 @@
 }
 
 storage::mojom::ServiceWorkerFindRegistrationResultPtr
-CreateSyntheticRegistration(const GURL& client_url,
-                            const blink::StorageKey& key) {
+GetOrCreateSyntheticRegistration(const GURL& client_url,
+                                 const blink::StorageKey& key) {
+  DCHECK_CURRENTLY_ON(BrowserThread::UI);
+  // Cache registration IDs based on the StorageKey so that subsequent calls
+  // for the same key return the same registration object. Using LRUCache to
+  // prevent unbounded memory growth.
+  constexpr int kMaxCachedRegistrations = 100;
+  static base::NoDestructor<base::LRUCache<blink::StorageKey, int64_t>>
+      key_to_registration_id_map(kMaxCachedRegistrations);
+
+  static int64_t synthetic_id_counter = 0;
+  int64_t registration_id;
+
+  auto it = key_to_registration_id_map->Get(key);
+  if (it != key_to_registration_id_map->end()) {
+    registration_id = it->second;
+  } else {
+    registration_id =
+        blink::mojom::kSyntheticResponseServiceWorkerRegistrationId -
+        synthetic_id_counter;
+    synthetic_id_counter++;
+    key_to_registration_id_map->Put({key, registration_id});
+  }
+
   GURL::Replacements replacements_for_script;
   replacements_for_script.ClearQuery();
   replacements_for_script.SetPathStr(
@@ -500,8 +523,7 @@
   }
 
   auto data = storage::mojom::ServiceWorkerRegistrationData::New();
-  data->registration_id =
-      blink::mojom::kSyntheticResponseServiceWorkerRegistrationId;
+  data->registration_id = registration_id;
   data->scope = kScope;
   data->key = key;
   data->script = kScript;
diff --git a/content/browser/service_worker/service_worker_loader_helpers.h b/content/browser/service_worker/service_worker_loader_helpers.h
index bc6c87233..ddb1eae 100644
--- a/content/browser/service_worker/service_worker_loader_helpers.h
+++ b/content/browser/service_worker/service_worker_loader_helpers.h
@@ -112,9 +112,9 @@
     const base::flat_set<std::string>& denied_url_params);
 bool IsSyntheticResponseDryRunModeEnabled();
 
-storage::mojom::ServiceWorkerFindRegistrationResultPtr
-CreateSyntheticRegistration(const GURL& client_url,
-                            const blink::StorageKey& key);
+CONTENT_EXPORT storage::mojom::ServiceWorkerFindRegistrationResultPtr
+GetOrCreateSyntheticRegistration(const GURL& client_url,
+                                 const blink::StorageKey& key);
 }  // namespace service_worker_loader_helpers
 
 }  // namespace content
diff --git a/content/browser/service_worker/service_worker_loader_helpers_unittest.cc b/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
index 1c1eb5e..690a52b 100644
--- a/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
+++ b/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
@@ -11,6 +11,9 @@
 #include "content/public/test/test_content_browser_client.h"
 #include "content/test/test_render_view_host.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/storage_key/storage_key.h"
+#include "url/gurl.h"
+#include "url/origin.h"
 
 namespace content {
 
@@ -329,6 +332,37 @@
       "http://other.com/foo/"));
 }
 
+TEST(ServiceWorkerLoaderHelpersTest, SyntheticResponseRegistrationCollision) {
+  content::BrowserTaskEnvironment task_environment;
+
+  // 1. Create a synthetic registration for origin A using the helper.
+  const GURL kClientUrlA("https://a.test/search?q=test");
+  const blink::StorageKey kKeyA =
+      blink::StorageKey::CreateFirstParty(url::Origin::Create(kClientUrlA));
+  auto resultA = GetOrCreateSyntheticRegistration(kClientUrlA, kKeyA);
+  ASSERT_TRUE(resultA);
+  ASSERT_TRUE(resultA->registration);
+
+  // Subsequent call for the same storage key should return the same registration ID.
+  auto resultA_again = GetOrCreateSyntheticRegistration(kClientUrlA, kKeyA);
+  ASSERT_TRUE(resultA_again);
+  ASSERT_TRUE(resultA_again->registration);
+  EXPECT_EQ(resultA->registration->registration_id,
+            resultA_again->registration->registration_id);
+
+  // It should automatically get a different ID for a different key.
+  const GURL kClientUrlB("https://b.test/search?q=test");
+  const blink::StorageKey kKeyB =
+      blink::StorageKey::CreateFirstParty(url::Origin::Create(kClientUrlB));
+  auto resultB = GetOrCreateSyntheticRegistration(kClientUrlB, kKeyB);
+  ASSERT_TRUE(resultB);
+  ASSERT_TRUE(resultB->registration);
+
+  // They should have different IDs.
+  EXPECT_NE(resultA->registration->registration_id,
+            resultB->registration->registration_id);
+}
+
 class ServiceWorkerLoaderHelpersSyntheticResponseTest
     : public RenderViewHostTestHarness,
       public testing::WithParamInterface<bool> {
diff --git a/content/browser/service_worker/service_worker_registry.cc b/content/browser/service_worker/service_worker_registry.cc
index 1bf7ba5..4ec4fc9 100644
--- a/content/browser/service_worker/service_worker_registry.cc
+++ b/content/browser/service_worker/service_worker_registry.cc
@@ -441,8 +441,8 @@
     // `ServiceWorkerBackgroundUpdateForFindRegistrationForClientUrl`) will
     // eliminate this IPC overhead.
     storage::mojom::ServiceWorkerFindRegistrationResultPtr result =
-        service_worker_loader_helpers::CreateSyntheticRegistration(client_url,
-                                                                   key);
+        service_worker_loader_helpers::GetOrCreateSyntheticRegistration(
+            client_url, key);
     DidFindRegistrationForClientUrl(
         client_url, key, trace_event_id, std::move(callback),
         storage::mojom::ServiceWorkerDatabaseStatus::kOk, std::move(result),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/service_worker/service_worker_loader_helpers_unittest.cc b/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
index 1c1eb5e..690a52b 100644
--- a/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
+++ b/content/browser/service_worker/service_worker_loader_helpers_unittest.cc
@@ -11,6 +11,9 @@
 #include "content/public/test/test_content_browser_client.h"
 #include "content/test/test_render_view_host.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/storage_key/storage_key.h"
+#include "url/gurl.h"
+#include "url/origin.h"
 
 namespace content {
 
@@ -329,6 +332,37 @@
       "http://other.com/foo/"));
 }
 
+TEST(ServiceWorkerLoaderHelpersTest, SyntheticResponseRegistrationCollision) {
+  content::BrowserTaskEnvironment task_environment;
+
+  // 1. Create a synthetic registration for origin A using the helper.
+  const GURL kClientUrlA("https://a.test/search?q=test");
+  const blink::StorageKey kKeyA =
+      blink::StorageKey::CreateFirstParty(url::Origin::Create(kClientUrlA));
+  auto resultA = GetOrCreateSyntheticRegistration(kClientUrlA, kKeyA);
+  ASSERT_TRUE(resultA);
+  ASSERT_TRUE(resultA->registration);
+
+  // Subsequent call for the same storage key should return the same registration ID.
+  auto resultA_again = GetOrCreateSyntheticRegistration(kClientUrlA, kKeyA);
+  ASSERT_TRUE(resultA_again);
+  ASSERT_TRUE(resultA_again->registration);
+  EXPECT_EQ(resultA->registration->registration_id,
+            resultA_again->registration->registration_id);
+
+  // It should automatically get a different ID for a different key.
+  const GURL kClientUrlB("https://b.test/search?q=test");
+  const blink::StorageKey kKeyB =
+      blink::StorageKey::CreateFirstParty(url::Origin::Create(kClientUrlB));
+  auto resultB = GetOrCreateSyntheticRegistration(kClientUrlB, kKeyB);
+  ASSERT_TRUE(resultB);
+  ASSERT_TRUE(resultB->registration);
+
+  // They should have different IDs.
+  EXPECT_NE(resultA->registration->registration_id,
+            resultB->registration->registration_id);
+}
+
 class ServiceWorkerLoaderHelpersSyntheticResponseTest
     : public RenderViewHostTestHarness,
       public testing::WithParamInterface<bool> {
Loading diff…

Original Bug Report

reported by vm...@google.com

UXSS via Service Worker ID Collision in Synthetic Responses

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Universal Cross-Site Scripting (UXSS) vulnerability exists when the kServiceWorkerSyntheticResponse feature is enabled. An attacker-controlled extension can exploit a hardcoded Service Worker registration ID and missing Release-build scope validations to install a malicious Service Worker as the controller for arbitrary victim origins. This allows complete interception of the victim’s network requests and arbitrary script execution.

Affected files:

  • content/browser/service_worker/service_worker_loader_helpers.cc
  • content/browser/service_worker/service_worker_registry.cc
  • content/browser/service_worker/service_worker_client.cc
  • chrome/common/extensions/manifest_handlers/settings_overrides_handler.cc
  • chrome/browser/extensions/api/settings_overrides/settings_overrides_api.cc
  • chrome/browser/chrome_content_browser_client.cc

Estimated timestamp from git blame: 2026-01-13

Description

There is a potential vulnerability in the Service Worker synthetic response mechanism (kServiceWorkerSyntheticResponse) that allows an attacker to hijack Service Worker registrations for arbitrary origins. By combining a lack of origin validation in the chrome_settings_overrides extension API with a hardcoded Service Worker ID and DCHECK-only scope validation, an attacker can achieve Universal Cross-Site Scripting (UXSS).

Note: These are suggested potential steps, as our setup does not currently have the ability to run code or functional exploits to verify them.

Potential Attack Scenario

  1. Malicious Extension Installation: An attacker publishes a Chrome extension using the chrome_settings_overrides permission to set a custom Default Search Engine (DSE). The manifest sets the search_url to an attacker-controlled origin (e.g., https://attacker.com/search?q={searchTerms}) and injects a victim origin into the alternate_urls list (e.g., https://victim.com/search?q={searchTerms}). ConvertSearchProvider does not strictly validate that alternate_urls match the search_url origin.
  2. Attacker SW Initialization: The user navigates to the attacker’s main search URL (https://attacker.com/search?q=foo). Because it’s the DSE, CreateSyntheticRegistration is called. It hardcodes data->registration_id to kSyntheticResponseServiceWorkerRegistrationId (which is -10). A new Service Worker registration scoped to https://attacker.com is created and stored in the ServiceWorkerContextCore::live_registrations_ map under ID -10.
  3. Victim Navigation & ID Collision: The user navigates to the victim URL (https://victim.com/search?q=bar). Because it’s in the DSE’s alternate_urls, it is deemed eligible for a synthetic response. CreateSyntheticRegistration is called again and creates a victim-scoped data object, but reuses the exact same hardcoded ID (-10).
  4. Retrieving the Wrong Registration: ServiceWorkerRegistry::GetOrCreateRegistration attempts to look up the ID -10. Because live_registrations_ is a flat map across the entire StoragePartition, it finds and returns the existing registration scoped to https://attacker.com, completely ignoring the victim scope in the new registration data.
  5. DCHECK Bypass and UXSS: The attacker’s registration is passed to the victim’s ServiceWorkerClient. In ServiceWorkerClient::AddMatchingRegistration and ServiceWorkerClient::SetControllerRegistration, the checks that verify the registration’s scope matches the client’s URL are implemented using DCHECKs (DCHECK(blink::ServiceWorkerScopeMatches(...)) and DCHECK(IsMatchingRegistration(...))). In Release builds, these checks are compiled out. The attacker’s Service Worker is successfully set as the controller for https://victim.com, allowing the attacker to intercept requests and inject arbitrary JavaScript.

Code References

  • Hardcoded ID: content/browser/service_worker/service_worker_loader_helpers.cc in CreateSyntheticRegistration uses blink::mojom::kSyntheticResponseServiceWorkerRegistrationId.
  • Cross-Origin Map Lookup: content/browser/service_worker/service_worker_registry.cc in GetOrCreateRegistration looks up context_->GetLiveRegistration(data.registration_id) without validating the origin matches the data.scope.
  • DCHECK-Only Validation: content/browser/service_worker/service_worker_client.cc relies on #if DCHECK_IS_ON() and DCHECK for scope matching when adding a matching registration and setting the controller registration.

Suggested Fix

  1. Eliminate Hardcoded IDs: Synthetic responses should dynamically allocate unique registration IDs per origin/StorageKey, rather than using a globally hardcoded ID (-10).
  2. Enforce Strict Scope Checks: Convert the DCHECKs in ServiceWorkerClient::AddMatchingRegistration and ServiceWorkerClient::SetControllerRegistration to CHECKs (or add safe early-returns) to ensure a Service Worker can never become the controller for a mismatched scope, even in Release builds.
  3. Validate Alternate URLs: In SettingsOverridesAPI::RegisterSearchProvider (or ConvertSearchProvider), validate that all alternate_urls share the same origin as the primary search_url or the extension itself.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker