CVE-2026-9115
Overview
Background
- Service Worker Static Routing API
- lets a service worker declare routes that resolve a request from a source such as
cachewithout running the fetch handler. - Opaque response
- a
network::mojom::FetchResponseType::kOpaqueresponse from a cross-originno-corsfetch, whose body/headers must not be exposed to the requesting origin. - `ServiceWorkerMainResourceLoader` / `ServiceWorkerSubresourceLoader`
- the browser- and renderer-process loaders that commit a service-worker-produced response to a navigation or subresource.
Root Cause Analysis
When a navigation matched a static route with a cache source, the pre-fix ServiceWorkerMainResourceLoader::DidDispatchFetchEvent recorded cache timing in the IsMatchedRouterSourceType(...kCache) branch (content/browser/service_worker/service_worker_main_resource_loader.cc:910-920) and committed the cached FetchAPIResponse without checking its response_type. Per the reporter, a cross-origin no-cors response stored via cache.put() keeps its internal status/headers/body while being marked kOpaque, so serving it as a navigation response exposed cross-origin data under the attacker’s origin — the renderer-side fetch-event validation that normally blocks opaque responses was bypassed because this path commits in the loader without the equivalent check.
The fix adds a shared validator in content/common/service_worker/service_worker_resource_loader.cc: IsValidServiceWorkerResponse(request_mode, redirect_mode, response) implements Fetch spec step 3.5.6, returning false when request_mode != kNoCors and response->response_type == kOpaque (and for kError, same-origin+kCors, kOpaqueRedirect, and multi-URL redirect cases), and IsValidStaticRouterResponse wraps it with UMA. DidDispatchFetchEvent now, for a kCache matched source, calls CommitCompleted(net::ERR_FAILED, "Invalid response from static router") when the response is invalid and features::kServiceWorkerStaticRouterOpaqueCheck is enabled (diff at pre-fix :920); the same guard is added to the renderer ServiceWorkerSubresourceLoader (diff near service_worker_subresource_loader.cc:1522). This restores the invariant that an opaque cross-origin response cannot be committed as a non-no-cors navigation/subresource.
cache path committed responses in the loader without the opaque-response check that the normal fetch-event path enforces; the fix adds a spec-based IsValidServiceWorkerResponse check that fails the load with net::ERR_FAILED for opaque responses.Attack Path
- Register a cache route
An attacker page’s service worker adds a static route matching a path with
source: 'cache'. (inferred: route-registration API details are from the report.) - Store a cross-origin opaque response
The page
no-cors-fetches a cross-origin resource andcache.put()s it, so the cache holds the internal status/headers/body of akOpaqueresponse. - Navigate to the route
Navigating to the matched path makes
DidDispatchFetchEventtake thekCachebranch; pre-fix it commits the cached response, exposing cross-origin status/headers/body under the attacker’s origin.
Impact Assessment
cache route, disclosing cross-origin HTTP status, headers, and certain bodies — a Same-Origin-Policy/ORB policy bypass in the browser. The advisory rates this high severity with no in-the-wild exploitation. Note the mitigation is gated behind kServiceWorkerStaticRouterOpaqueCheck, which ships disabled by default.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/common/service_worker/service_worker_resource_loader.cc |
modified |
Files Changed
content/browser/service_worker/service_worker_browsertest.cccontent/browser/service_worker/service_worker_main_resource_loader.cccontent/common/features.cccontent/common/features.hcontent/common/service_worker/service_worker_resource_loader.cccontent/common/service_worker/service_worker_resource_loader.h
Audit Directions
- Parity across response pathsWhen one response path (renderer fetch-event) enforces a security check, verify alternate paths (static router, cache source) that commit responses enforce the same check.
- Opaque/type checks before commitAudit loaders that commit a
FetchAPIResponseto confirmresponse_type(kOpaque,kError,kOpaqueRedirect) is validated against the request mode before exposing body/headers. - Flag-gated security fixesTrack
FEATURE_DISABLED_BY_DEFAULTsecurity guards so the protection is known to be inactive until the flag is enabled.
Patch
From 1cd1e8607c8e1a4bc58f2c54448bf34857eba8a6 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
Date: Wed, 01 Apr 2026 18:14:48 -0700
Subject: [PATCH] Block invalid responses for Static Router cache source
This CL implements a security fix for the Service Worker Static Routing
API. When a navigation request matches a 'cache' source and receives an
opaque response, it is now blocked with net::ERR_FAILED if the new
feature flag kServiceWorkerStaticRouterOpaqueCheck is enabled.
This also follows https://fetch.spec.whatwg.org/#concept-http-fetch
Step 3.5.6.
This is implemented in both ServiceWorkerMainResourceLoader (browser)
and ServiceWorkerSubresourceLoader (renderer) to ensure comprehensive
coverage of requests.
A new UMA ServiceWorker.StaticRouter.*.OpaqueResponse is
added to track how often this case occurs in the wild.
Bug: 495999481
Change-Id: Icd72687e6d79fd98312e70d0d9a072f75f595e8e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7703459
Reviewed-by: Shunya Shishido <sisidovski@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608913}
---
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index a392f654..d7b7ce3 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -36,6 +36,7 @@
#include "base/task/sequenced_task_runner.h"
#include "base/task/task_traits.h"
#include "base/test/bind.h"
+#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/with_feature_override.h"
#include "base/time/time.h"
@@ -7465,6 +7466,13 @@
// The result should be got from the cache, and no network access is
// expected.
EXPECT_EQ(0, GetRequestCount(relative_url));
+
+ // Sync histograms from the renderer process.
+ FetchHistogramsFromChildProcesses();
+
+ // Check UMA (success case should record true).
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.Subresource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
diff --git a/content/browser/service_worker/service_worker_main_resource_loader.cc b/content/browser/service_worker/service_worker_main_resource_loader.cc
index aa64af2..67ee2206 100644
--- a/content/browser/service_worker/service_worker_main_resource_loader.cc
+++ b/content/browser/service_worker/service_worker_main_resource_loader.cc
@@ -917,6 +917,17 @@
cache_matcher_->cache_lookup_start();
response_head_->service_worker_router_info->cache_lookup_time =
cache_matcher_->cache_lookup_duration();
+
+ // Block invalid responses from the static router.
+ if (response_head_->service_worker_router_info->matched_source_type ==
+ network::mojom::ServiceWorkerRouterSourceType::kCache) {
+ if (!IsValidStaticRouterResponse(resource_request_, response) &&
+ base::FeatureList::IsEnabled(
+ features::kServiceWorkerStaticRouterOpaqueCheck)) {
+ CommitCompleted(net::ERR_FAILED, "Invalid response from static router");
+ return;
+ }
+ }
}
// Record the timing of when the fetch event is dispatched on the worker
diff --git a/content/common/features.cc b/content/common/features.cc
index 41938e3..cd9ed686 100644
--- a/content/common/features.cc
+++ b/content/common/features.cc
@@ -677,6 +677,12 @@
BASE_FEATURE(kServiceWorkerStaticRouterRaceRequestFix2,
base::FEATURE_ENABLED_BY_DEFAULT);
+// crbug.com/495999481: When this is enabled, the navigation request should be
+// blocked when it receives an opaque response from the service worker static
+// router.
+BASE_FEATURE(kServiceWorkerStaticRouterOpaqueCheck,
+ base::FEATURE_DISABLED_BY_DEFAULT);
+
// (crbug.com/1371756): When enabled, the static routing API starts
// ServiceWorker when the routing result of a main resource request was network
// fallback.
diff --git a/content/common/features.h b/content/common/features.h
index cf6bf92..590eca2b 100644
--- a/content/common/features.h
+++ b/content/common/features.h
@@ -213,6 +213,7 @@
kServiceWorkerBypassFetchHandlerBypassedHashStrings;
CONTENT_EXPORT BASE_DECLARE_FEATURE(kServiceWorkerSrcdocSupport);
CONTENT_EXPORT BASE_DECLARE_FEATURE(kServiceWorkerStaticRouterRaceRequestFix2);
+CONTENT_EXPORT BASE_DECLARE_FEATURE(kServiceWorkerStaticRouterOpaqueCheck);
CONTENT_EXPORT BASE_DECLARE_FEATURE(
kServiceWorkerStaticRouterStartServiceWorker);
CONTENT_EXPORT BASE_DECLARE_FEATURE(
diff --git a/content/common/service_worker/service_worker_resource_loader.cc b/content/common/service_worker/service_worker_resource_loader.cc
index 9c907e5e..b917f19 100644
--- a/content/common/service_worker/service_worker_resource_loader.cc
+++ b/content/common/service_worker/service_worker_resource_loader.cc
@@ -6,13 +6,77 @@
#include "base/check_op.h"
#include "base/feature_list.h"
+#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
+#include "base/strings/strcat.h"
#include "base/trace_event/trace_event.h"
#include "content/public/common/content_features.h"
+#include "services/network/public/cpp/resource_request.h"
+#include "services/network/public/mojom/fetch_api.mojom.h"
#include "services/network/public/mojom/service_worker_router_info.mojom-shared.h"
+#include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom.h"
#include "third_party/perfetto/include/perfetto/tracing/track_event_args.h"
namespace content {
+
+// static
+bool ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode request_mode,
+ network::mojom::RedirectMode redirect_mode,
+ const blink::mojom::FetchAPIResponsePtr& response) {
+ // This validation follows the Fetch spec.
+ // 4.4 HTTP fetch, Step 3.5.6.
+ // If one of the following is true
+ // - response’s type is "error"
+ // - request’s mode is "same-origin" and response’s type is "cors"
+ // - request’s mode is not "no-cors" and response’s type is "opaque"
+ // - request’s redirect mode is not "manual" and response’s type is
+ // "opaqueredirect"
+ // - request’s redirect mode is not "follow" and response’s URL list has more
+ // than one item
+ // then return a network error.
+ // Note: Existing validation functions do not fully follow the spec.
+ if (!response) {
+ return true;
+ }
+
+ if (response->response_type == network::mojom::FetchResponseType::kError) {
+ return false;
+ }
+ if (request_mode == network::mojom::RequestMode::kSameOrigin &&
+ response->response_type == network::mojom::FetchResponseType::kCors) {
+ return false;
+ }
+ if (request_mode != network::mojom::RequestMode::kNoCors &&
+ response->response_type == network::mojom::FetchResponseType::kOpaque) {
+ return false;
+ }
+ if (redirect_mode != network::mojom::RedirectMode::kManual &&
+ response->response_type ==
+ network::mojom::FetchResponseType::kOpaqueRedirect) {
+ return false;
+ }
+ if (redirect_mode != network::mojom::RedirectMode::kFollow &&
+ response->url_list.size() > 1) {
+ return false;
+ }
+
+ return true;
+}
+
+bool ServiceWorkerResourceLoader::IsValidStaticRouterResponse(
+ const network::ResourceRequest& resource_request,
+ const blink::mojom::FetchAPIResponsePtr& response) {
+ bool is_valid = IsValidServiceWorkerResponse(
+ resource_request.mode, resource_request.redirect_mode, response);
+ base::UmaHistogramBoolean(
+ base::StrCat({"ServiceWorker.StaticRouter.",
+ IsMainResourceLoader() ? "MainResource" : "Subresource",
+ ".ValidResponse"}),
+ is_valid);
+ return is_valid;
+}
+
ServiceWorkerResourceLoader::ServiceWorkerResourceLoader() = default;
ServiceWorkerResourceLoader::~ServiceWorkerResourceLoader() = default;
diff --git a/content/common/service_worker/service_worker_resource_loader.h b/content/common/service_worker/service_worker_resource_loader.h
index b74b9c7f..d0ec8bd 100644
--- a/content/common/service_worker/service_worker_resource_loader.h
+++ b/content/common/service_worker/service_worker_resource_loader.h
@@ -9,9 +9,19 @@
#include "base/check_op.h"
#include "content/common/content_export.h"
+#include "mojo/public/cpp/base/big_buffer.h"
#include "services/network/public/mojom/service_worker_router_info.mojom-shared.h"
#include "services/network/public/mojom/url_loader.mojom.h"
#include "third_party/blink/public/common/service_worker/service_worker_router_rule.h"
+#include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom-forward.h"
Regression Test / PoC
diff --git a/content/browser/service_worker/service_worker_browsertest.cc b/content/browser/service_worker/service_worker_browsertest.cc
index a392f654..d7b7ce3 100644
--- a/content/browser/service_worker/service_worker_browsertest.cc
+++ b/content/browser/service_worker/service_worker_browsertest.cc
@@ -36,6 +36,7 @@
#include "base/task/sequenced_task_runner.h"
#include "base/task/task_traits.h"
#include "base/test/bind.h"
+#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/with_feature_override.h"
#include "base/time/time.h"
@@ -7465,6 +7466,13 @@
// The result should be got from the cache, and no network access is
// expected.
EXPECT_EQ(0, GetRequestCount(relative_url));
+
+ // Sync histograms from the renderer process.
+ FetchHistogramsFromChildProcesses();
+
+ // Check UMA (success case should record true).
+ histogram_tester().ExpectBucketCount(
+ "ServiceWorker.StaticRouter.Subresource.ValidResponse", true, 1);
}
IN_PROC_BROWSER_TEST_F(ServiceWorkerStaticRouterBrowserTest,
diff --git a/content/common/service_worker/service_worker_resource_loader_unittest.cc b/content/common/service_worker/service_worker_resource_loader_unittest.cc
new file mode 100644
index 0000000..1a4fe5c
--- /dev/null
+++ b/content/common/service_worker/service_worker_resource_loader_unittest.cc
@@ -0,0 +1,102 @@
+// 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 "content/common/service_worker/service_worker_resource_loader.h"
+
+#include "services/network/public/mojom/fetch_api.mojom.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom.h"
+
+namespace content {
+
+TEST(ServiceWorkerResourceLoaderTest, IsValidServiceWorkerResponse) {
+ auto request_mode = network::mojom::RequestMode::kSameOrigin;
+ auto redirect_mode = network::mojom::RedirectMode::kFollow;
+
+ // Normal response.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kDefault;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, redirect_mode, response));
+ }
+
+ // Error response.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kError;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, redirect_mode, response));
+ }
+
+ // same-origin request and cors response.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kCors;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kSameOrigin, redirect_mode, response));
+ }
+
+ // cross-origin request (cors) and cors response (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kCors;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kCors, redirect_mode, response));
+ }
+
+ // cors request and opaque response.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kCors, redirect_mode, response));
+ }
+
+ // no-cors request and opaque response (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type = network::mojom::FetchResponseType::kOpaque;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ network::mojom::RequestMode::kNoCors, redirect_mode, response));
+ }
+
+ // opaqueredirect response and manual redirect mode (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type =
+ network::mojom::FetchResponseType::kOpaqueRedirect;
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, network::mojom::RedirectMode::kManual, response));
+ }
+
+ // opaqueredirect response and follow redirect mode.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->response_type =
+ network::mojom::FetchResponseType::kOpaqueRedirect;
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, network::mojom::RedirectMode::kFollow, response));
+ }
+
+ // multiple URLs in URL list and follow redirect mode (OK).
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->url_list.emplace_back("http://a.test/1");
+ response->url_list.emplace_back("http://a.test/2");
+ EXPECT_TRUE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, network::mojom::RedirectMode::kFollow, response));
+ }
+
+ // multiple URLs in URL list and manual redirect mode.
+ {
+ auto response = blink::mojom::FetchAPIResponse::New();
+ response->url_list.emplace_back("http://a.test/1");
+ response->url_list.emplace_back("http://a.test/2");
+ EXPECT_FALSE(ServiceWorkerResourceLoader::IsValidServiceWorkerResponse(
+ request_mode, network::mojom::RedirectMode::kManual, response));
+ }
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 45de489..c2340db 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -3107,6 +3107,7 @@
"../common/service_worker/race_network_request_simple_buffer_manager_unittest.cc",
"../common/service_worker/race_network_request_url_loader_client_unittest.cc",
"../common/service_worker/race_network_request_write_buffer_manager_unittest.cc",
+ "../common/service_worker/service_worker_resource_loader_unittest.cc",
"../common/service_worker/service_worker_router_evaluator_unittest.cc",
"../common/url_utils_unittest.cc",
"../common/web_ui_loading_util_unittest.cc",
Original Bug Report
Information Disclosure: SW Static Routing bypasses Opaque Response validation
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: The Service Worker Static Routing API fails to validate the response type when serving from a cache source during navigation. This allows an attacker to serve opaque cross-origin responses as a navigation response, potentially leaking internal metadata and data to the attacker’s origin.
Affected files:
content/browser/service_worker/service_worker_cache_storage_matcher.cccontent/browser/service_worker/service_worker_main_resource_loader.cccontent/browser/service_worker/service_worker_loader_helpers.cc
Estimated timestamp from git blame: 2025-06-26
Vulnerability Detail
A logic vulnerability in the Service Worker Static Routing API allows a web attacker to potentially bypass the Same-Origin Policy (SOP) and Opaque Response Blocking (ORB) constraints for certain resource types.
In the standard Service Worker fetch event path, the renderer-side FetchRespondWithObserver enforces that kOpaque responses cannot be served to client requests (such as navigations). However, when using the Static Routing API with a cache source, this validation is bypassed because the response is handled within the browser process without equivalent checks.
When a cross-origin resource is fetched with mode: 'no-cors' and stored using cache.put(), Blink’s FetchResponseData::PopulateFetchAPIResponse recurses into the internal_response_. This means the Cache Storage actually stores the internal status code (e.g., 200), headers, and body, even though the response type is marked as kOpaque.
When a navigation matches a static route with a cache source:
ServiceWorkerCacheStorageMatcher::DidMatchreceives the cachedFetchAPIResponsebut fails to verify if theresponse_typeiskOpaquebefore passing it to the loader.- In
ServiceWorkerMainResourceLoader::DidDispatchFetchEvent, the checkstatus_code != 0passes because the internal status code (e.g., 200) was retrieved from the cache. ServiceWorkerLoaderHelpers::SaveResponseInfothen copies the internal headers and status code into the navigation’sURLResponseHeadwithout filtering.- The browser commits the response body as a same-origin document on the attacker’s origin.
Impact
A malicious website can register a Service Worker that uses a static route to serve a cross-origin resource from the cache. By doing so, the attacker can potentially:
- Perform cross-origin login detection or existence probes by reading the exact HTTP status code of a victim’s resource.
- Exfiltrate internal HTTP headers from cross-origin requests.
- Read the contents of JS, CSS, images, and media files from another origin (which are permitted by ORB during the initial ’no-cors’ fetch but would normally be opaque to the attacker).
While ORB strips HTML, JSON, and XML bodies during the initial network fetch, the leak of status codes and other resource types constitutes a significant information disclosure.
Potential Reproduction Steps
These are suggested steps to trigger the vulnerability:
- Serve a page from
attacker.comwith a Service Worker. The SW install handler should callevent.addRouteswith a route that matches a specific path (e.g.,/leak) and usessource: 'cache'. - From the attacker’s page, perform a fetch:
const r = await fetch('https://victim.example/api/status', {mode:'no-cors'});. - Store the result:
const c = await caches.open('x'); await c.put('/leak', r);. - Navigate to
https://attacker.com/leak(e.g., inside an iframe). - The navigation will commit the victim’s internal response data under the
attacker.comorigin. - The attacker can then observe the navigation status or use
iframe.contentDocumentto infer the victim’s response status, headers, and allowed bodies.
Suggested Fix
Add a check in ServiceWorkerCacheStorageMatcher::DidMatch or ServiceWorkerMainResourceLoader::DidDispatchFetchEvent to explicitly reject responses with response_type == network::mojom::FetchResponseType::kOpaque when handling a navigation request. If an opaque response is encountered, it should be treated as a network error, mirroring the behavior of the renderer-side FetchRespondWithObserver.
Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651
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.