CVE-2026-7958
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTcontent/browser/service_worker/service_worker_loader_helpers_unittest.cc |
modified | |
ServiceWorkerLoaderHelpersSyntheticResponseTestcontent/browser/service_worker/service_worker_loader_helpers_unittest.cc |
modified |
Files Changed
content/browser/service_worker/service_worker_loader_helpers.cccontent/browser/service_worker/service_worker_loader_helpers.hcontent/browser/service_worker/service_worker_loader_helpers_unittest.cccontent/browser/service_worker/service_worker_registry.cc
Patch
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),
Regression Test / PoC
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> {
Original Bug Report
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.cccontent/browser/service_worker/service_worker_registry.cccontent/browser/service_worker/service_worker_client.ccchrome/common/extensions/manifest_handlers/settings_overrides_handler.ccchrome/browser/extensions/api/settings_overrides/settings_overrides_api.ccchrome/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
- Malicious Extension Installation: An attacker publishes a Chrome extension using the
chrome_settings_overridespermission to set a custom Default Search Engine (DSE). The manifest sets thesearch_urlto an attacker-controlled origin (e.g.,https://attacker.com/search?q={searchTerms}) and injects a victim origin into thealternate_urlslist (e.g.,https://victim.com/search?q={searchTerms}).ConvertSearchProviderdoes not strictly validate thatalternate_urlsmatch thesearch_urlorigin. - Attacker SW Initialization: The user navigates to the attacker’s main search URL (
https://attacker.com/search?q=foo). Because it’s the DSE,CreateSyntheticRegistrationis called. It hardcodesdata->registration_idtokSyntheticResponseServiceWorkerRegistrationId(which is-10). A new Service Worker registration scoped tohttps://attacker.comis created and stored in theServiceWorkerContextCore::live_registrations_map under ID-10. - Victim Navigation & ID Collision: The user navigates to the victim URL (
https://victim.com/search?q=bar). Because it’s in the DSE’salternate_urls, it is deemed eligible for a synthetic response.CreateSyntheticRegistrationis called again and creates a victim-scoped data object, but reuses the exact same hardcoded ID (-10). - Retrieving the Wrong Registration:
ServiceWorkerRegistry::GetOrCreateRegistrationattempts to look up the ID-10. Becauselive_registrations_is a flat map across the entireStoragePartition, it finds and returns the existing registration scoped tohttps://attacker.com, completely ignoring the victim scope in the new registration data. - DCHECK Bypass and UXSS: The attacker’s registration is passed to the victim’s
ServiceWorkerClient. InServiceWorkerClient::AddMatchingRegistrationandServiceWorkerClient::SetControllerRegistration, the checks that verify the registration’s scope matches the client’s URL are implemented usingDCHECKs (DCHECK(blink::ServiceWorkerScopeMatches(...))andDCHECK(IsMatchingRegistration(...))). In Release builds, these checks are compiled out. The attacker’s Service Worker is successfully set as the controller forhttps://victim.com, allowing the attacker to intercept requests and inject arbitrary JavaScript.
Code References
- Hardcoded ID:
content/browser/service_worker/service_worker_loader_helpers.ccinCreateSyntheticRegistrationusesblink::mojom::kSyntheticResponseServiceWorkerRegistrationId. - Cross-Origin Map Lookup:
content/browser/service_worker/service_worker_registry.ccinGetOrCreateRegistrationlooks upcontext_->GetLiveRegistration(data.registration_id)without validating the origin matches thedata.scope. - DCHECK-Only Validation:
content/browser/service_worker/service_worker_client.ccrelies on#if DCHECK_IS_ON()andDCHECKfor scope matching when adding a matching registration and setting the controller registration.
Suggested Fix
- Eliminate Hardcoded IDs: Synthetic responses should dynamically allocate unique registration IDs per origin/StorageKey, rather than using a globally hardcoded ID (
-10). - Enforce Strict Scope Checks: Convert the
DCHECKs inServiceWorkerClient::AddMatchingRegistrationandServiceWorkerClient::SetControllerRegistrationtoCHECKs (or add safe early-returns) to ensure a Service Worker can never become the controller for a mismatched scope, even in Release builds. - Validate Alternate URLs: In
SettingsOverridesAPI::RegisterSearchProvider(orConvertSearchProvider), validate that allalternate_urlsshare the same origin as the primarysearch_urlor 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.