Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in ServiceWorker
DescriptionInformation leak in ServiceWorker
ComponentServiceWorker
Bug ClassLogic Error
Tracker517092658
Fix commitd9ea860c25d2 (chromium/src) +83/-17
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
if
content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
modified
FakeServiceWorkerContainerHost
content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
modified
TEST_F
content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
modified
for
content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
modified

Files Changed

  • content/renderer/service_worker/service_worker_subresource_loader.cc
  • content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
  • third_party/blink/common/service_worker/service_worker_loader_helpers.cc
From d9ea860c25d2ee9dc42e56ee37425c4935b194bc Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
Date: Tue, 11 Aug 2026 22:11:53 -0700
Subject: [PATCH] ServiceWorker: Run Timing-Allow-Origin check for filtered subresource responses

CL 8182169 set timing_allow_passed to true only for kBasic and kDefault
response types, and unconditionally false for filtered response types.
However, filtered subresource responses (such as kCors or kOpaque)
that provide a valid Timing-Allow-Origin header matching the request
initiator should also pass the Timing Allow check.

This CL updates ServiceWorkerSubresourceLoader::StartResponse to run
network::TimingAllowOriginCheck on response_head_->parsed_headers
against resource_request_.request_initiator for filtered responses,
consistent with ServiceWorkerMainResourceLoader.

TAG=agy
CONV=b2563ec8-2e72-4911-be75-51e4aa2cf6bd

Bug: 517092658
Change-Id: Ib8cbc494b9aa0df12213e1eed6c1a97150607ac7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8246358
Reviewed-by: Shunya Shishido <sisidovski@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1677769}
---

diff --git a/content/renderer/service_worker/service_worker_subresource_loader.cc b/content/renderer/service_worker/service_worker_subresource_loader.cc
index edcc641c..cdfcf45 100644
--- a/content/renderer/service_worker/service_worker_subresource_loader.cc
+++ b/content/renderer/service_worker/service_worker_subresource_loader.cc
@@ -32,6 +32,7 @@
 #include "services/network/public/cpp/record_ontransfersizeupdate_utils.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
+#include "services/network/public/cpp/timing_allow_origin_parser.h"
 #include "services/network/public/mojom/early_hints.mojom.h"
 #include "services/network/public/mojom/service_worker_router_info.mojom.h"
 #include "services/network/public/mojom/url_response_head.mojom.h"
@@ -873,12 +874,19 @@
   // Synthetic and same-origin responses are same-origin to the requesting
   // client, so the timing allow check trivially passes. Filtered responses
   // wrap a cross-origin response for which the timing allow check must not
-  // be assumed to have passed.
-  response_head_->timing_allow_passed =
-      response_head_->response_type ==
+  // be assumed to have passed unless the Timing-Allow-Origin check passes.
+  if (response_head_->response_type ==
           network::mojom::FetchResponseType::kBasic ||
       response_head_->response_type ==
-          network::mojom::FetchResponseType::kDefault;
+          network::mojom::FetchResponseType::kDefault) {
+    response_head_->timing_allow_passed = true;
+  } else if (resource_request_.request_initiator &&
+             response_head_->parsed_headers &&
+             network::TimingAllowOriginCheck(
+                 response_head_->parsed_headers->timing_allow_origin,
+                 *resource_request_.request_initiator)) {
+    response_head_->timing_allow_passed = true;
+  }
 
   // Set the actual source type to `kFetchEvent` if nothing is set yet.
   auto* router_info = response_head_->service_worker_router_info.get();
diff --git a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
index 637bf76..9f03236 100644
--- a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
+++ b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
@@ -11,13 +11,13 @@
 #include <vector>
 
 #include "base/containers/span.h"
+#include "base/debug/stack_trace.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/notimplemented.h"
 #include "base/run_loop.h"
 #include "base/strings/string_number_conversions.h"
-#include "base/debug/stack_trace.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
 #include "base/test/metrics/histogram_tester.h"
@@ -33,6 +33,7 @@
 #include "net/http/http_util.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
 #include "net/url_request/url_request.h"
+#include "services/network/public/cpp/timing_allow_origin_parser.h"
 #include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
 #include "services/network/test/test_data_pipe_getter.h"
 #include "services/network/test/test_url_loader_client.h"
@@ -128,7 +129,8 @@
       base::Time response_time,
       std::string cache_storage_cache_name,
       network::mojom::FetchResponseType response_type =
-          network::mojom::FetchResponseType::kDefault) {
+          network::mojom::FetchResponseType::kDefault,
+      network::mojom::ParsedHeadersPtr parsed_headers = nullptr) {
     auto response = blink::mojom::FetchAPIResponse::New();
     response->status_code = 200;
     response->status_text = "OK";
@@ -136,6 +138,7 @@
     response->response_source = response_source;
     response->response_time = response_time;
     response->cache_storage_cache_name = cache_storage_cache_name;
+    response->parsed_headers = std::move(parsed_headers);
     response->blob = std::move(blob_body);
     if (response->blob) {
       response->headers.emplace("Content-Length",
@@ -273,6 +276,14 @@
     response_type_ = response_type;
   }
 
+  void SetTimingAllowOrigin(const std::string& tao_string) {
+    parsed_headers_ = network::mojom::ParsedHeaders::New();
+    parsed_headers_->timing_allow_origin =
+        network::ParseTimingAllowOrigin(tao_string);
+  }
+
+  void ResetTimingAllowOrigin() { parsed_headers_.reset(); }
+
   // blink::mojom::ControllerServiceWorker:
   void DispatchFetchEventForSubresource(
       blink::mojom::DispatchFetchEventParamsPtr params,
@@ -297,7 +308,8 @@
         response_callback->OnResponse(
             OkResponse(nullptr /* blob_body */, response_source_,
                        response_time_, cache_storage_cache_name_,
-                       response_type_),
+                       response_type_,
+                       parsed_headers_ ? parsed_headers_->Clone() : nullptr),
             std::move(timing), /*errors=*/nullptr);
         std::move(callback).Run(
             blink::mojom::ServiceWorkerEventStatus::COMPLETED);
@@ -456,6 +468,7 @@
 
   std::string cache_storage_cache_name_;
   base::Time response_time_;
+  network::mojom::ParsedHeadersPtr parsed_headers_;
 };
 
 class FakeServiceWorkerContainerHost
@@ -1757,22 +1770,42 @@
 TEST_F(ServiceWorkerSubresourceLoaderTest, TimingAllowPassedByResponseType) {
   struct {
     network::mojom::FetchResponseType response_type;
+    std::optional<std::string> timing_allow_origin;
     bool expected_timing_allow_passed;
   } kTestCases[] = {
-      {network::mojom::FetchResponseType::kBasic, true},
-      {network::mojom::FetchResponseType::kCors, false},
-      {network::mojom::FetchResponseType::kDefault, true},
-      {network::mojom::FetchResponseType::kError, false},
-      {network::mojom::FetchResponseType::kOpaque, false},
-      {network::mojom::FetchResponseType::kOpaqueRedirect, false},
+      // Basic and Default responses always pass timing allow check.
+      {network::mojom::FetchResponseType::kBasic, std::nullopt, true},
+      {network::mojom::FetchResponseType::kDefault, std::nullopt, true},
+      // Filtered responses without Timing-Allow-Origin header fail.
+      {network::mojom::FetchResponseType::kCors, std::nullopt, false},
+      {network::mojom::FetchResponseType::kError, std::nullopt, false},
+      {network::mojom::FetchResponseType::kOpaque, std::nullopt, false},
+      {network::mojom::FetchResponseType::kOpaqueRedirect, std::nullopt, false},
+      // Filtered responses with valid Timing-Allow-Origin pass.
+      {network::mojom::FetchResponseType::kCors, "*", true},
+      {network::mojom::FetchResponseType::kOpaque, "*", true},
+      {network::mojom::FetchResponseType::kCors, "https://www.example.com",
+       true},
+      // Filtered responses with mismatching Timing-Allow-Origin fail.
+      {network::mojom::FetchResponseType::kCors, "https://other.example.com",
+       false},
+      {network::mojom::FetchResponseType::kOpaque, "https://other.example.com",
+       false},
   };
 
   for (const auto& test_case : kTestCases) {
     fake_controller_.SetResponseType(test_case.response_type);
+    if (test_case.timing_allow_origin) {
+      fake_controller_.SetTimingAllowOrigin(*test_case.timing_allow_origin);
+    } else {
+      fake_controller_.ResetTimingAllowOrigin();
+    }
     mojo::Remote<network::mojom::URLLoaderFactory> factory =
         CreateSubresourceLoaderFactory();
     network::ResourceRequest request =
         CreateRequest(GURL("https://www.example.com/foo.png"));
+    request.request_initiator =
+        url::Origin::Create(GURL("https://www.example.com"));
     mojo::Remote<network::mojom::URLLoader> loader;
     std::unique_ptr<network::TestURLLoaderClient> client;
     StartRequest(factory, request, &loader, &client);
diff --git a/third_party/blink/common/service_worker/service_worker_loader_helpers.cc b/third_party/blink/common/service_worker/service_worker_loader_helpers.cc
index cf2a042..0ab5dc9 100644
--- a/third_party/blink/common/service_worker/service_worker_loader_helpers.cc
+++ b/third_party/blink/common/service_worker/service_worker_loader_helpers.cc
@@ -159,6 +159,7 @@
   out_head->was_fetched_via_spdy = response.was_fetched_via_spdy;
   out_head->has_range_requested = response.has_range_requested;
   out_head->auth_challenge_info = response.auth_challenge_info;
+  out_head->timing_allow_passed = false;
   SaveResponseHeaders(response, out_head);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
index 637bf76..9f03236 100644
--- a/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
+++ b/content/renderer/service_worker/service_worker_subresource_loader_unittest.cc
@@ -11,13 +11,13 @@
 #include <vector>
 
 #include "base/containers/span.h"
+#include "base/debug/stack_trace.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/notimplemented.h"
 #include "base/run_loop.h"
 #include "base/strings/string_number_conversions.h"
-#include "base/debug/stack_trace.h"
 #include "base/strings/stringprintf.h"
 #include "base/test/bind.h"
 #include "base/test/metrics/histogram_tester.h"
@@ -33,6 +33,7 @@
 #include "net/http/http_util.h"
 #include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
 #include "net/url_request/url_request.h"
+#include "services/network/public/cpp/timing_allow_origin_parser.h"
 #include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
 #include "services/network/test/test_data_pipe_getter.h"
 #include "services/network/test/test_url_loader_client.h"
@@ -128,7 +129,8 @@
       base::Time response_time,
       std::string cache_storage_cache_name,
       network::mojom::FetchResponseType response_type =
-          network::mojom::FetchResponseType::kDefault) {
+          network::mojom::FetchResponseType::kDefault,
+      network::mojom::ParsedHeadersPtr parsed_headers = nullptr) {
     auto response = blink::mojom::FetchAPIResponse::New();
     response->status_code = 200;
     response->status_text = "OK";
@@ -136,6 +138,7 @@
     response->response_source = response_source;
     response->response_time = response_time;
     response->cache_storage_cache_name = cache_storage_cache_name;
+    response->parsed_headers = std::move(parsed_headers);
     response->blob = std::move(blob_body);
     if (response->blob) {
       response->headers.emplace("Content-Length",
@@ -273,6 +276,14 @@
     response_type_ = response_type;
   }
 
+  void SetTimingAllowOrigin(const std::string& tao_string) {
+    parsed_headers_ = network::mojom::ParsedHeaders::New();
+    parsed_headers_->timing_allow_origin =
+        network::ParseTimingAllowOrigin(tao_string);
+  }
+
+  void ResetTimingAllowOrigin() { parsed_headers_.reset(); }
+
   // blink::mojom::ControllerServiceWorker:
   void DispatchFetchEventForSubresource(
       blink::mojom::DispatchFetchEventParamsPtr params,
@@ -297,7 +308,8 @@
         response_callback->OnResponse(
             OkResponse(nullptr /* blob_body */, response_source_,
                        response_time_, cache_storage_cache_name_,
-                       response_type_),
+                       response_type_,
+                       parsed_headers_ ? parsed_headers_->Clone() : nullptr),
             std::move(timing), /*errors=*/nullptr);
         std::move(callback).Run(
             blink::mojom::ServiceWorkerEventStatus::COMPLETED);
@@ -456,6 +468,7 @@
 
   std::string cache_storage_cache_name_;
   base::Time response_time_;
+  network::mojom::ParsedHeadersPtr parsed_headers_;
 };
 
 class FakeServiceWorkerContainerHost
@@ -1757,22 +1770,42 @@
 TEST_F(ServiceWorkerSubresourceLoaderTest, TimingAllowPassedByResponseType) {
   struct {
     network::mojom::FetchResponseType response_type;
+    std::optional<std::string> timing_allow_origin;
     bool expected_timing_allow_passed;
   } kTestCases[] = {
-      {network::mojom::FetchResponseType::kBasic, true},
-      {network::mojom::FetchResponseType::kCors, false},
-      {network::mojom::FetchResponseType::kDefault, true},
-      {network::mojom::FetchResponseType::kError, false},
-      {network::mojom::FetchResponseType::kOpaque, false},
-      {network::mojom::FetchResponseType::kOpaqueRedirect, false},
+      // Basic and Default responses always pass timing allow check.
+      {network::mojom::FetchResponseType::kBasic, std::nullopt, true},
+      {network::mojom::FetchResponseType::kDefault, std::nullopt, true},
+      // Filtered responses without Timing-Allow-Origin header fail.
+      {network::mojom::FetchResponseType::kCors, std::nullopt, false},
+      {network::mojom::FetchResponseType::kError, std::nullopt, false},
+      {network::mojom::FetchResponseType::kOpaque, std::nullopt, false},
+      {network::mojom::FetchResponseType::kOpaqueRedirect, std::nullopt, false},
+      // Filtered responses with valid Timing-Allow-Origin pass.
+      {network::mojom::FetchResponseType::kCors, "*", true},
+      {network::mojom::FetchResponseType::kOpaque, "*", true},
+      {network::mojom::FetchResponseType::kCors, "https://www.example.com",
+       true},
+      // Filtered responses with mismatching Timing-Allow-Origin fail.
+      {network::mojom::FetchResponseType::kCors, "https://other.example.com",
+       false},
+      {network::mojom::FetchResponseType::kOpaque, "https://other.example.com",
+       false},
   };
 
   for (const auto& test_case : kTestCases) {
     fake_controller_.SetResponseType(test_case.response_type);
+    if (test_case.timing_allow_origin) {
+      fake_controller_.SetTimingAllowOrigin(*test_case.timing_allow_origin);
+    } else {
+      fake_controller_.ResetTimingAllowOrigin();
+    }
     mojo::Remote<network::mojom::URLLoaderFactory> factory =
         CreateSubresourceLoaderFactory();
     network::ResourceRequest request =
         CreateRequest(GURL("https://www.example.com/foo.png"));
+    request.request_initiator =
+        url::Origin::Create(GURL("https://www.example.com"));
     mojo::Remote<network::mojom::URLLoader> loader;
     std::unique_ptr<network::TestURLLoaderClient> client;
     StartRequest(factory, request, &loader, &client);
diff --git a/third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc b/third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
index e0f1b3e3..455b11c 100644
--- a/third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
+++ b/third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
@@ -122,4 +122,17 @@
             head->headers->GetNormalizedHeader("Server-Timing"));
 }
 
+TEST(ServiceWorkerLoaderHelpersTest, SaveResponseInfo_TimingAllowPassedReset) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kOpaque;
+
+  auto head = network::mojom::URLResponseHead::New();
+  head->timing_allow_passed = true;
+  ServiceWorkerLoaderHelpers::SaveResponseInfo(*response, head.get());
+
+  EXPECT_FALSE(head->timing_allow_passed);
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin-server-timing.https.html b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin-server-timing.https.html
index 5dda087..0b53b5d 100644
--- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin-server-timing.https.html
+++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin-server-timing.https.html
@@ -73,16 +73,25 @@
     description: 'Cross-origin CORS response without TAO'
   });
 
-  // 3. Cross-origin CORS response with TAO: filtered response (kCors), timing allow check fails for client.
+  // 3. Cross-origin CORS response with TAO: filtered response (kCors), timing allow check passes for client.
   const remote_cors_tao = `${host_info.HTTPS_REMOTE_ORIGIN}${base_path()}resources/server-timing.py?cors=1&tao=1`;
   await test_fetch({
     target: remote_cors_tao,
     mode: 'cors',
-    expect_timing_allow: false,
+    expect_timing_allow: true,
     description: 'Cross-origin CORS response with TAO'
   });
 
-  // 4. Synthetic response created inside Service Worker: synthetic response (kDefault), timing allow check passes.
+  // 4. Cross-origin opaque response (no-cors) with TAO: filtered response (kOpaque), timing allow check passes for client.
+  const remote_opaque_tao = `${host_info.HTTPS_REMOTE_ORIGIN}${base_path()}resources/server-timing.py?tao=1`;
+  await test_fetch({
+    target: remote_opaque_tao,
+    mode: 'no-cors',
+    expect_timing_allow: true,
+    description: 'Cross-origin opaque response (no-cors) with TAO'
+  });
+
+  // 5. Synthetic response created inside Service Worker: synthetic response (kDefault), timing allow check passes.
   await test_fetch({
     target: 'synthetic',
     mode: 'cors',
@@ -90,7 +99,7 @@
     description: 'Synthetic response from ServiceWorker'
   });
 
-  // 5. Same-origin response (kBasic): timing allow check passes.
+  // 6. Same-origin response (kBasic): timing allow check passes.
   const same_origin_url = `${host_info.HTTPS_ORIGIN}${base_path()}resources/server-timing.py`;
   await test_fetch({
     target: same_origin_url,
diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resources/server-timing.py b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resources/server-timing.py
index c4ffcc9c..2e5b4bf 100644
--- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resources/server-timing.py
+++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resources/server-timing.py
@@ -10,6 +10,8 @@
         headers.append((b"Timing-Allow-Origin", b"*"))
     if b"cors" in request.GET:
         headers.append((b"Access-Control-Allow-Origin", b"*"))
+        headers.append((b"Access-Control-Expose-Headers",
+                        b"Server-Timing, Timing-Allow-Origin"))
 
     # 1x1 transparent PNG
     png_data = decodebytes(
Loading diff…

Original Bug Report

reported by vm...@google.com

Timing-Allow-Origin and SOP bypass in ServiceWorkerSubresourceLoader

Project Fortify, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential vulnerability in ServiceWorkerSubresourceLoader unconditionally sets the timing_allow_passed flag to true for all subresources served through a Service Worker. This can potentially allow same-origin pages to read sensitive cross-origin Server-Timing headers and exact response sizes of opaque resources, bypassing the Same-Origin Policy and Timing-Allow-Origin restrictions. This issue does not require a compromised renderer to exploit.

Affected files:

  • content/renderer/service_worker/service_worker_subresource_loader.cc
  • third_party/blink/renderer/core/fetch/fetch_response_data.cc
  • third_party/blink/common/service_worker/service_worker_loader_helpers.cc

Estimated timestamp from git blame: 2022-09-07

Root Cause Analysis

In content/renderer/service_worker/service_worker_subresource_loader.cc inside ServiceWorkerSubresourceLoader::StartResponse, the response’s timing_allow_passed flag is unconditionally set to true for any subresource response served through a Service Worker:

// content/renderer/service_worker/service_worker_subresource_loader.cc
blink::ServiceWorkerLoaderHelpers::SaveResponseInfo(*response,
                                                    response_head_.get());
...
// Constructed subresource responses are always same-origin as the requesting
// client.
response_head_->timing_allow_passed = true;

This assumption is incorrect when a Service Worker fetch handler intercepts a request and resolves it via a cross-origin response (e.g., e.respondWith(fetch(crossOriginURL, {mode: 'no-cors'}))). In this scenario, the response is actually cross-origin and opaque, and should not have its timing/size metadata exposed to the requesting origin.

During response serialization, FetchResponseData::PopulateFetchAPIResponse in third_party/blink/renderer/core/fetch/fetch_response_data.cc copies the unfiltered internal response headers (including Server-Timing) into the Mojo FetchAPIResponse::headers map. These headers are then populated verbatim into response_head_->headers via SaveResponseInfo calling SaveResponseHeaders -> GetHttpResponseHeaders in third_party/blink/common/service_worker/service_worker_loader_helpers.cc.

Because timing_allow_passed is set to true, the page-side loader evaluates response->TimingAllowPassed() as true. This causes resource_timing_utils.cc to parse the Server-Timing headers and expose them directly via the Performance Timeline. Furthermore, since ResourceTimingUseCORSForBodySizes is disabled in production by default, forcing timing_allow_passed to true also incorrectly exposes the detailed encodedBodySize and decodedBodySize of opaque responses.


Potential Attack Steps

Note: The following are potential steps that an attacker might follow to trigger the vulnerability. Our tooling agent does not have the capability to run code to confirm this with a live PoC.

  1. An attacker-controlled page registers a Service Worker that listens for fetch events.
  2. The Service Worker fetch event listener intercepts a same-origin subresource request (e.g., /probe) and responds with a cross-origin no-cors fetch to a sensitive victim endpoint:
    event.respondWith(fetch('https://victim.example/api', {mode: 'no-cors', credentials: 'include'}));
    
  3. The page initiates the load of /probe (e.g., via <img src="/probe"> or fetch('/probe')).
  4. The loader process handles the request through ServiceWorkerSubresourceLoader, which overrides timing_allow_passed to true and delivers the unfiltered headers to the page-side ResourceResponse.
  5. The attacker’s page JavaScript queries the performance timeline:
    const entry = performance.getEntriesByName(location.origin + '/probe')[0];
    console.log(entry.serverTiming); // Discloses parsed cross-origin Server-Timing fields
    console.log(entry.encodedBodySize); // Discloses exact cross-origin response body size
    

Suggested Fix

Instead of unconditionally setting timing_allow_passed = true inside ServiceWorkerSubresourceLoader::StartResponse, the loader should perform a proper origin and/or Timing-Allow-Origin check similar to the one implemented in ServiceWorkerMainResourceLoader::StartResponse:

if (resource_request_.request_initiator && response_head_->parsed_headers &&
    (resource_request_.request_initiator->IsSameOriginWith(resource_request_.url) ||
     network::TimingAllowOriginCheck(response_head_->parsed_headers->timing_allow_origin,
                                     *resource_request_.request_initiator))) {
  response_head_->timing_allow_passed = true;
}

This ensures that timing details and body sizes of cross-origin, opaque responses served by a Service Worker are not leaked to unauthorized origins.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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