Medium watch CVSS 4.3 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in ServiceWorker
DescriptionInformation leak in ServiceWorker
ComponentServiceWorker
Sub-componentServiceWorker
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker524541667
Fix commite010835ad397 (chromium/src) +142/-0
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-16 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

Background

`ServiceWorker` `FetchEvent.respondWith`
a service worker can return a Response for a navigation or subresource, and the browser turns that FetchAPIResponse into a URLResponseHead for the committed document.
`FetchResponseType::kCors`
the response type for a cross-origin response fetched with CORS, which per the Fetch spec must be exposed as a “CORS filtered response” limiting visible headers to the CORS-safelist plus Access-Control-Expose-Headers names.
`FetchAPIResponse`
the mojo struct carrying the internal response’s full header list (response.headers), its response_type, and cors_exposed_header_names.
CORS-safelisted response headers
a fixed set (cache-control, content-language, content-length, content-type, expires, last-modified, pragma, plus content-range for media) that may be surfaced cross-origin without explicit exposure.

Root Cause Analysis

ServiceWorkerLoaderHelpers::GetHttpResponseHeaders rebuilt the net::HttpResponseHeaders for URLResponseHead by copying every entry of response.headers regardless of response.response_type. For a kCors response — whose response.headers holds the internal (unfiltered) header list — this surfaced all internal headers to downstream consumers, namely the committed document’s response headers and the Static Router header re-parse path, violating the Fetch “CORS filtered response” invariant that a cross-origin response must not reveal non-safelisted, non-exposed headers.

The fix computes is_cors_filtered = (response.response_type == FetchResponseType::kCors) and, inside the copy loop, skips any header for which the new IsCorsExposedResponseHeader returns false, so only CORS-safelisted names and names listed in response.cors_exposed_header_names are carried through. Basic and default responses are unchanged because the guard only triggers for kCors. A new kCorsSafelistedResponseHeaderNames fixed-flat-set mirrors the renderer’s cors.cc list and is lint-bound to it via LINT.IfChange/LINT.ThenChange.

Key insight
The core mistake was that header-list rebuilding for the service-worker response path ignored response_type and copied the internal response’s full headers, so a kCors response leaked non-exposed headers; the fix applies the CORS response-header filter during that rebuild.

Attack Path

  1. Control a service worker An attacker who controls a service worker (or its served responses) issues a cross-origin fetch and returns the resulting kCors Response via respondWith.
  2. Internal headers retained GetHttpResponseHeaders copies the internal response’s entire header list into HttpResponseHeaders/URLResponseHead without CORS filtering.
  3. Observe leaked headers Non-safelisted, non-exposed headers (e.g., Server-Timing, Link, or a custom X-Hidden from the cross-origin server) become readable via the committed document’s response headers and the Static Router header re-parse path.
  4. Disclosure Header values that CORS is designed to hide from cross-origin script are exposed, an information leak from another origin’s response.

Impact Assessment

An attacker leveraging a service worker can read cross-origin response headers that the CORS filtered-response rules should hide, gaining an information disclosure across origin boundaries in the renderer/document context, with the precondition that the leaked data flows through the service-worker response path (committed document headers or the Static Router re-parse). The advisory classifies this as an information leak in ServiceWorker and does not report in-the-wild exploitation; the concrete leak is header exposure, and any further consequence is (inferred).

Changed Functions

FunctionChangeNotes
for
third_party/blink/common/service_worker/service_worker_loader_helpers.cc
modified
BlobCompleteCaller
third_party/blink/common/service_worker/service_worker_loader_helpers.cc
modified
TEST
third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
modified

Files Changed

  • third_party/blink/renderer/platform/loader/cors/cors.cc
  • third_party/blink/common/service_worker/service_worker_loader_helpers.cc
  • third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
  • third_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc

Audit Directions

  • `response_type`-agnostic header copying
    Audit every place that turns a FetchAPIResponse or internal response into headers/URLResponseHead and verify it honors response_type (applies CORS/opaque filtering) rather than copying response.headers wholesale.
  • Duplicated safelists across layers
    Look for other CORS-safelist or exposed-header lists duplicated between common and renderer (or network service) that are not lint-bound and could diverge.
  • Downstream re-parse consumers
    Check consumers like the Static Router header re-parse path that read the rebuilt headers, ensuring they never see more than the filtered set for cross-origin responses.
From e010835ad3979c20846eba07b2925ccc8a400c44 Mon Sep 17 00:00:00 2001
From: Shunya Shishido <[email protected]>
Date: Tue, 07 Jul 2026 23:26:18 -0700
Subject: [PATCH] ServiceWorker: Apply CORS filter when building response headers

FetchAPIResponse carries the internal response's full header list
together with the response_type and the list of CORS- exposed header
names. ServiceWorkerLoaderHelpers:: GetHttpResponseHeaders() previously
copied every header into the HttpResponseHeaders used for
URLResponseHead, regardless of response_type, so a kCors response
surfaced its internal headers to downstream consumers (the committed
document's response headers and the Static Router header re-parse path).

Restrict the rebuilt header list for kCors responses to the
CORS-safelisted response header names plus any names in
cors_exposed_header_names, matching the Fetch "CORS filtered response"
definition. Basic and default responses are unchanged.

Add unit tests covering GetHttpResponseHeaders and SaveResponseInfo for
kCors and kBasic responses.

Update audit_non_blink_usage.py to allowlist base::MakeFixedFlatSet,
base::ToLowerASCII, base::EqualsCaseInsensitiveASCII, and
network::mojom::FetchResponseType in service_worker_loader_helpers.cc,
as third_party/blink/common/ requires allowlisting non-std and non-blink
utilities.

TAG=agy

Bug: 524541667
Change-Id: Id4ef2bd8ae47e436422f19a3cce491bc91efebc5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017364
Reviewed-by: Kentaro Hara <[email protected]>
Reviewed-by: Yoshisato Yanagisawa <[email protected]>
Commit-Queue: Shunya Shishido <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1658520}
---
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 276927a6..5e07b46 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
@@ -7,10 +7,13 @@
 #include <limits>
 #include <memory>
 #include <string>
+#include <string_view>
 #include <utility>
 #include <vector>
 
+#include "base/containers/fixed_flat_set.h"
 #include "base/strings/strcat.h"
+#include "base/strings/string_util.h"
 #include "base/strings/to_string.h"
 #include "mojo/public/cpp/bindings/self_owned_receiver.h"
 #include "net/http/http_response_headers.h"
@@ -28,6 +31,40 @@
 namespace blink {
 namespace {
 
+// LINT.IfChange(kCorsSafelistedResponseHeaderNames)
+// https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
+constexpr auto kCorsSafelistedResponseHeaderNames =
+    base::MakeFixedFlatSet<std::string_view>({
+        "cache-control",
+        "content-language",
+        "content-length",
+        // "content-range" is not a standard CORS-safelisted response header,
+        // but it is required by C++ media loaders (e.g. WebMediaPlayer) to
+        // process "206 Partial Content" range responses. We permit it in
+        // URLResponseHead to avoid breaking media playback, while it remains
+        // filtered out and hidden from JavaScript's view in the renderer.
+        "content-range",
+        "content-type",
+        "expires",
+        "last-modified",
+        "pragma",
+    });
+// LINT.ThenChange(third_party/blink/renderer/platform/loader/cors/cors.cc:allowed_cross_origin_response_headers)
+
+bool IsCorsExposedResponseHeader(
+    std::string_view name,
+    const std::vector<std::string>& cors_exposed_header_names) {
+  if (kCorsSafelistedResponseHeaderNames.contains(base::ToLowerASCII(name))) {
+    return true;
+  }
+  for (const auto& exposed : cors_exposed_header_names) {
+    if (base::EqualsCaseInsensitiveASCII(name, exposed)) {
+      return true;
+    }
+  }
+  return false;
+}
+
 // Calls |callback| when Blob reading is complete.
 class BlobCompleteCaller : public mojom::BlobReaderClient {
  public:
@@ -228,7 +265,18 @@
   std::string status = base::StrCat(
       {base::ToString(response.status_code), " ", response.status_text});
   net::HttpResponseHeaders::Builder builder({1, 1}, status);
+  // |response.headers| holds the header list of the internal response. For a
+  // CORS filtered response, restrict the resulting header list to the
+  // CORS-safelisted response headers and any explicitly exposed names.
+  // https://fetch.spec.whatwg.org/#concept-filtered-response-cors
+  const bool is_cors_filtered =
+      response.response_type == network::mojom::FetchResponseType::kCors;
   for (const auto& item : response.headers) {
+    if (is_cors_filtered &&
+        !IsCorsExposedResponseHeader(item.first,
+                                     response.cors_exposed_header_names)) {
+      continue;
+    }
     builder.AddHeader(item.first, item.second);
   }
   return builder.Build();
diff --git a/third_party/blink/renderer/platform/loader/cors/cors.cc b/third_party/blink/renderer/platform/loader/cors/cors.cc
index 53fadafe..107209d2 100644
--- a/third_party/blink/renderer/platform/loader/cors/cors.cc
+++ b/third_party/blink/renderer/platform/loader/cors/cors.cc
@@ -205,6 +205,7 @@
 }
 
 bool IsCorsSafelistedResponseHeader(const String& name) {
+  // LINT.IfChange(allowed_cross_origin_response_headers)
   // https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
   // TODO(dcheng): Consider using a flat_set here with a transparent comparator.
   DEFINE_THREAD_SAFE_STATIC_LOCAL(HTTPHeaderSet,
@@ -218,6 +219,7 @@
                                       "last-modified",
                                       "pragma",
                                   }));
+  // LINT.ThenChange(third_party/blink/common/service_worker/service_worker_loader_helpers.cc:kCorsSafelistedResponseHeaderNames)
   return allowed_cross_origin_response_headers.contains(name.Ascii());
 }
 
diff --git a/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py b/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
index ea470517..4195e77 100755
--- a/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
+++ b/third_party/blink/tools/blinkpy/presubmit/audit_non_blink_usage.py
@@ -547,6 +547,17 @@
     },
     {
         'paths': [
+            'third_party/blink/common/service_worker/service_worker_loader_helpers.cc',
+        ],
+        'allowed': [
+            'base::EqualsCaseInsensitiveASCII',
+            'base::MakeFixedFlatSet',
+            'base::ToLowerASCII',
+            'network::mojom::FetchResponseType',
+        ],
+    },
+    {
+        'paths': [
             'third_party/blink/common/navigation/navigation_params.cc',
         ],
         'allowed': [
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 461ce9e3..e0f1b3e3 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
@@ -5,6 +5,8 @@
 #include "third_party/blink/public/common/service_worker/service_worker_loader_helpers.h"
 
 #include "net/http/http_response_headers.h"
+#include "services/network/public/mojom/fetch_api.mojom-shared.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom.h"
 
@@ -41,4 +43,83 @@
   EXPECT_EQ("Not Found", headers->GetStatusText());
 }
 
+TEST(ServiceWorkerLoaderHelpersTest, GetHttpResponseHeaders_CorsFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Cache-Control"] = "no-store";
+  response->headers["Server-Timing"] = "metric;desc=value";
+  response->headers["Link"] = "</a>; rel=preload";
+  response->headers["X-Exposed"] = "ok";
+  response->headers["X-Hidden"] = "no";
+  response->cors_exposed_header_names = {"x-exposed"};
+
+  scoped_refptr<net::HttpResponseHeaders> headers =
+      ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(*response);
+
+  ASSERT_TRUE(headers);
+  EXPECT_EQ(200, headers->response_code());
+  EXPECT_EQ("text/html", headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_EQ("no-store", headers->GetNormalizedHeader("Cache-Control"));
+  EXPECT_EQ("ok", headers->GetNormalizedHeader("X-Exposed"));
+  EXPECT_FALSE(headers->HasHeader("Server-Timing"));
+  EXPECT_FALSE(headers->HasHeader("Link"));
+  EXPECT_FALSE(headers->HasHeader("X-Hidden"));
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, GetHttpResponseHeaders_CorsNoExposed) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Length"] = "10";
+  response->headers["Content-Range"] = "bytes 0-9/10";
+  response->headers["Server-Timing"] = "metric;desc=value";
+
+  scoped_refptr<net::HttpResponseHeaders> headers =
+      ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(*response);
+
+  ASSERT_TRUE(headers);
+  EXPECT_EQ("10", headers->GetNormalizedHeader("Content-Length"));
+  EXPECT_EQ("bytes 0-9/10", headers->GetNormalizedHeader("Content-Range"));
+  EXPECT_FALSE(headers->HasHeader("Server-Timing"));
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, SaveResponseInfo_CorsFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Server-Timing"] = "metric;desc=value";
+  response->cors_exposed_header_names = {"X-Exposed"};
+
+  auto head = network::mojom::URLResponseHead::New();
+  ServiceWorkerLoaderHelpers::SaveResponseInfo(*response, head.get());
+
+  ASSERT_TRUE(head->headers);
+  EXPECT_EQ("text/html", head->headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_FALSE(head->headers->HasHeader("Server-Timing"));
+  EXPECT_EQ(network::mojom::FetchResponseType::kCors, head->response_type);
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, SaveResponseInfo_BasicNotFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kBasic;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Server-Timing"] = "metric;desc=value";
+
+  auto head = network::mojom::URLResponseHead::New();
+  ServiceWorkerLoaderHelpers::SaveResponseInfo(*response, head.get());
+
+  ASSERT_TRUE(head->headers);
+  EXPECT_EQ("text/html", head->headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_EQ("metric;desc=value",
+            head->headers->GetNormalizedHeader("Server-Timing"));
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
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 461ce9e3..e0f1b3e3 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
@@ -5,6 +5,8 @@
 #include "third_party/blink/public/common/service_worker/service_worker_loader_helpers.h"
 
 #include "net/http/http_response_headers.h"
+#include "services/network/public/mojom/fetch_api.mojom-shared.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/mojom/fetch/fetch_api_response.mojom.h"
 
@@ -41,4 +43,83 @@
   EXPECT_EQ("Not Found", headers->GetStatusText());
 }
 
+TEST(ServiceWorkerLoaderHelpersTest, GetHttpResponseHeaders_CorsFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Cache-Control"] = "no-store";
+  response->headers["Server-Timing"] = "metric;desc=value";
+  response->headers["Link"] = "</a>; rel=preload";
+  response->headers["X-Exposed"] = "ok";
+  response->headers["X-Hidden"] = "no";
+  response->cors_exposed_header_names = {"x-exposed"};
+
+  scoped_refptr<net::HttpResponseHeaders> headers =
+      ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(*response);
+
+  ASSERT_TRUE(headers);
+  EXPECT_EQ(200, headers->response_code());
+  EXPECT_EQ("text/html", headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_EQ("no-store", headers->GetNormalizedHeader("Cache-Control"));
+  EXPECT_EQ("ok", headers->GetNormalizedHeader("X-Exposed"));
+  EXPECT_FALSE(headers->HasHeader("Server-Timing"));
+  EXPECT_FALSE(headers->HasHeader("Link"));
+  EXPECT_FALSE(headers->HasHeader("X-Hidden"));
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, GetHttpResponseHeaders_CorsNoExposed) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Length"] = "10";
+  response->headers["Content-Range"] = "bytes 0-9/10";
+  response->headers["Server-Timing"] = "metric;desc=value";
+
+  scoped_refptr<net::HttpResponseHeaders> headers =
+      ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(*response);
+
+  ASSERT_TRUE(headers);
+  EXPECT_EQ("10", headers->GetNormalizedHeader("Content-Length"));
+  EXPECT_EQ("bytes 0-9/10", headers->GetNormalizedHeader("Content-Range"));
+  EXPECT_FALSE(headers->HasHeader("Server-Timing"));
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, SaveResponseInfo_CorsFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kCors;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Server-Timing"] = "metric;desc=value";
+  response->cors_exposed_header_names = {"X-Exposed"};
+
+  auto head = network::mojom::URLResponseHead::New();
+  ServiceWorkerLoaderHelpers::SaveResponseInfo(*response, head.get());
+
+  ASSERT_TRUE(head->headers);
+  EXPECT_EQ("text/html", head->headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_FALSE(head->headers->HasHeader("Server-Timing"));
+  EXPECT_EQ(network::mojom::FetchResponseType::kCors, head->response_type);
+}
+
+TEST(ServiceWorkerLoaderHelpersTest, SaveResponseInfo_BasicNotFiltered) {
+  auto response = mojom::FetchAPIResponse::New();
+  response->status_code = 200;
+  response->status_text = "OK";
+  response->response_type = network::mojom::FetchResponseType::kBasic;
+  response->headers["Content-Type"] = "text/html";
+  response->headers["Server-Timing"] = "metric;desc=value";
+
+  auto head = network::mojom::URLResponseHead::New();
+  ServiceWorkerLoaderHelpers::SaveResponseInfo(*response, head.get());
+
+  ASSERT_TRUE(head->headers);
+  EXPECT_EQ("text/html", head->headers->GetNormalizedHeader("Content-Type"));
+  EXPECT_EQ("metric;desc=value",
+            head->headers->GetNormalizedHeader("Server-Timing"));
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by [email protected]

Service Worker navigation leak of unfiltered kCors response headers

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 Chromium’s Service Worker navigation handling propagates the full, unfiltered internal headers of a cross-origin CORS response to the committed document. Consequently, non-CORS-exposed headers such as Server-Timing are leaked and can be read via script-accessible timing APIs of the same-origin document. This occurs because the browser process commits the navigation with the response’s full unfiltered internal header list instead of applying the CORS-filtered header list.

Affected files:

  • third_party/blink/common/service_worker/service_worker_loader_helpers.cc
  • third_party/blink/renderer/core/fetch/fetch_response_data.cc
  • third_party/blink/renderer/core/loader/document_loader.cc
  • content/common/service_worker/service_worker_resource_loader.cc
  • content/browser/service_worker/service_worker_main_resource_loader.cc

Estimated timestamp from git blame: 2018-05-15

Potential Service Worker Navigation Leak of Unfiltered kCors Response Headers

Summary

We have identified a potential cross-origin information leak in Chromium’s Service Worker navigation handling. When a Service Worker responds to a same-origin navigation request (kNavigate) with a CORS-filtered response (kCors) from a cross-origin endpoint, the committed document is populated with the response’s full unfiltered internal header list instead of the CORS-filtered header list.

As a result, sensitive headers that are specifically withheld from CORS exposure (such as Server-Timing or others) can be leaked to the committed document and read by script via PerformanceNavigationTiming.serverTiming or observed through document-processing side effects (e.g., Link preloads or relative CSP report-uri endpoints).

Note: These findings and reproduction steps are based on manual static code analysis; our current tooling does not have the capability to run code to confirm with an active proof of concept.


Root Cause Analysis

The potential vulnerability consists of three main links in the data propagation path, none of which apply CORS-exposed header filtering:

1. Renderer-side Serialization Recurses into the Internal Unfiltered Response

When a Service Worker supplies a response via respondWith (or via the Static-Router cache), it calls Response::PopulateFetchAPIResponse to serialize the response into a Mojo struct (mojom::blink::FetchAPIResponsePtr).

In third_party/blink/renderer/core/fetch/fetch_response_data.cc:

mojom::blink::FetchAPIResponsePtr FetchResponseData::PopulateFetchAPIResponse(
    const KURL& request_url) {
  if (internal_response_) {
    mojom::blink::FetchAPIResponsePtr response =
        internal_response_->PopulateFetchAPIResponse(request_url);  // ← Recurses into unfiltered internal response
    response->response_type = type_;                                // = kCors
    response->cors_exposed_header_names =
        HeaderSetToVector(cors_exposed_header_names_);
    return response;
  }
  ...
  for (const auto& header : HeaderList()->List())
    response->headers.insert(header.first, header.second);          // ← Copies all unfiltered headers

Because internal_response_ represents the unfiltered underlying response, the recursive call populates response->headers with the unfiltered header list. The outer response only overlays response_type = kCors and copies cors_exposed_header_names to the Mojo payload.

2. Browser-side Loader Copies the Full Map with No Filtering

In the browser process, ServiceWorkerMainResourceLoader::StartResponse receives the FetchAPIResponse and populates the navigation’s URLResponseHead response headers by calling SaveResponseInfo and SaveResponseHeaders:

In third_party/blink/common/service_worker/service_worker_loader_helpers.cc:

void SaveResponseHeaders(const mojom::FetchAPIResponse& response,
                         network::mojom::URLResponseHead* out_head) {
  out_head->headers =
      ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(response);
  ...
}

scoped_refptr<net::HttpResponseHeaders>
ServiceWorkerLoaderHelpers::GetHttpResponseHeaders(
    const blink::mojom::FetchAPIResponse& response) {
  ...
  for (const auto& item : response.headers) {        // ← Full internal header map
    builder.AddHeader(item.first, item.second);      // ← Missing validation against response.cors_exposed_header_names
  }
  return builder.Build();
}

Although response.cors_exposed_header_names is copied to out_head->cors_exposed_header_names, the loader never uses it to filter out_head->headers. As a result, the browser commits the navigation using the full, unfiltered header list of the cross-origin response.

3. DocumentLoader Unconditionally Bypasses Timing Constraints

When the navigation commits, DocumentLoader builds its ResourceResponse from the URLResponseHead received from the browser. For navigation timing, DocumentLoader unconditionally overrides timing-allow permissions because a document is considered same-origin with its own timeline:

In third_party/blink/renderer/core/loader/document_loader.cc:

response_.SetTimingAllowPassed(true);
mojom::blink::ResourceTimingInfoPtr navigation_timing_info =
    CreateResourceTimingInfo(base::TimeTicks(), ... , &response_);

Because response_ contains the unfiltered raw headers from the browser and TimingAllowPassed is set to true, CreateResourceTimingInfo reads the unfiltered Server-Timing header field and populates PerformanceNavigationTiming:

In third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc:

if (response->TimingAllowPassed()) {
  info->allow_timing_details = true;
  info->server_timing = ParseServerTimingFromHeaderValueToMojo(
      response->HttpHeaderField(http_names::kServerTiming));   // ← Extracts the unfiltered header

Suggested/Potential Trigger Path

An attacker could potentially exploit this behavior via the following steps:

  1. A victim API at https://victim.example/api responds with Access-Control-Allow-Origin: * and a sensitive Server-Timing: backend;desc="shard-id=123" header that is not exposed in Access-Control-Expose-Headers.
  2. The attacker registers a Service Worker on https://attacker.example/ that intercepts navigation requests to /leak and responds with the CORS fetch:
    self.addEventListener('fetch', e => {
      if (new URL(e.request.url).pathname === '/leak' && e.request.mode === 'navigate') {
        e.respondWith(fetch('https://victim.example/api', { mode: 'cors' }));
      }
    });
    
  3. The attacker page creates a same-origin iframe targeting /leak:
    const iframe = document.createElement('iframe');
    iframe.src = '/leak';
    document.body.appendChild(iframe);
    
  4. Once loaded, the attacker’s main page accesses the iframe’s performance entries:
    const entry = iframe.contentWindow.performance.getEntriesByType('navigation')[0];
    console.log(entry.serverTiming[0].description); // Outputs "shard-id=123"
    

Suggested Fix

To prevent this leak, the browser process must filter the headers before copying them into the navigation response metadata.

Specifically, in ServiceWorkerLoaderHelpers::GetHttpResponseHeaders (or inside SaveResponseHeaders), if the response has response_type == network::mojom::FetchResponseType::kCors, we should only copy the headers that are explicitly allowed in response.cors_exposed_header_names (and any CORS-safelisted response headers), rather than copying the entire response.headers map.

Evaluated with Chrome root at commit: 70c6813870b6701fa16670076bf633ee6c3a439f


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