Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Service Worker
DescriptionInsufficient policy enforcement in Service Worker
ComponentService Worker
Bug ClassLogic Error
Tracker495999481
Fix commit1cd1e8607c8e (chromium/src) +245/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-19

Changed Functions

FunctionChangeNotes
if
content/common/service_worker/service_worker_resource_loader.cc
modified

Files Changed

  • content/browser/service_worker/service_worker_browsertest.cc
  • content/browser/service_worker/service_worker_main_resource_loader.cc
  • content/common/features.cc
  • content/common/features.h
  • content/common/service_worker/service_worker_resource_loader.cc
  • content/common/service_worker/service_worker_resource_loader.h
From 1cd1e8607c8e1a4bc58f2c54448bf34857eba8a6 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <[email protected]>
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 <[email protected]>
Commit-Queue: Yoshisato Yanagisawa <[email protected]>
Reviewed-by: Rakina Zata Amni <[email protected]>
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"
Loading diff…

Regression Test / PoC

shipped with the fix
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",
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/browser/service_worker/service_worker_main_resource_loader.cc
  • content/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:

  1. ServiceWorkerCacheStorageMatcher::DidMatch receives the cached FetchAPIResponse but fails to verify if the response_type is kOpaque before passing it to the loader.
  2. In ServiceWorkerMainResourceLoader::DidDispatchFetchEvent, the check status_code != 0 passes because the internal status code (e.g., 200) was retrieved from the cache.
  3. ServiceWorkerLoaderHelpers::SaveResponseInfo then copies the internal headers and status code into the navigation’s URLResponseHead without filtering.
  4. 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:

  1. Serve a page from attacker.com with a Service Worker. The SW install handler should call event.addRoutes with a route that matches a specific path (e.g., /leak) and uses source: 'cache'.
  2. From the attacker’s page, perform a fetch: const r = await fetch('https://victim.example/api/status', {mode:'no-cors'});.
  3. Store the result: const c = await caches.open('x'); await c.put('/leak', r);.
  4. Navigate to https://attacker.com/leak (e.g., inside an iframe).
  5. The navigation will commit the victim’s internal response data under the attacker.com origin.
  6. The attacker can then observe the navigation status or use iframe.contentDocument to 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.

View on issue tracker
Links in the report