CVE-2026-79252
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/common/service_worker/service_worker_loader_helpers.cc |
modified | |
BlobCompleteCallerthird_party/blink/common/service_worker/service_worker_loader_helpers.cc |
modified | |
TESTthird_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc |
modified |
Files Changed
third_party/blink/common/service_worker/service_worker_loader_helpers.ccthird_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
Patch
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/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;
Regression Test / PoC
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
Original Bug Report
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.ccthird_party/blink/renderer/core/fetch/fetch_response_data.ccthird_party/blink/renderer/core/loader/document_loader.cccontent/common/service_worker/service_worker_resource_loader.cccontent/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:
- A victim API at
https://victim.example/apiresponds withAccess-Control-Allow-Origin: *and a sensitiveServer-Timing: backend;desc="shard-id=123"header that is not exposed inAccess-Control-Expose-Headers. - The attacker registers a Service Worker on
https://attacker.example/that intercepts navigation requests to/leakand 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' })); } }); - The attacker page creates a same-origin iframe targeting
/leak:const iframe = document.createElement('iframe'); iframe.src = '/leak'; document.body.appendChild(iframe); - 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.