CVE-2026-87574
Overview
Background
- `timing_allow_passed`
- a boolean on
network::mojom::URLResponseHeadthat records whether the Timing-Allow check passed and thereby gates exposure of detailed cross-origin timing (Resource Timing phases andServer-Timingheader values) to the requesting document. - Filtered response
- a
network::mojom::FetchResponseTypesuch askCorsorkOpaquethat wraps a cross-origin response and exposes only a restricted view of it to the caller. - `network::TimingAllowOriginCheck`
- compares a response’s parsed
Timing-Allow-Originheader against the request initiator’s origin to decide whether timing details may be revealed. - `ServiceWorkerLoaderHelpers::SaveResponseInfo`
- copies fields from a service-worker-produced
blink::mojom::FetchAPIResponseinto anetwork::mojom::URLResponseHeadbefore the response is delivered to the loader.
Root Cause Analysis
When a service worker serves a subresource, ServiceWorkerLoaderHelpers::SaveResponseInfo populates a URLResponseHead from the worker’s FetchAPIResponse, and ServiceWorkerSubresourceLoader::StartResponse then decides the value of timing_allow_passed. The patched StartResponse no longer assigns timing_allow_passed unconditionally; its new if / else if only ever sets the flag to true (for kBasic/kDefault, or for a filtered response whose network::TimingAllowOriginCheck passes against resource_request_.request_initiator) and leaves the flag untouched on the filtered-response-fails-TAO fall-through path. That fall-through now relies on the incoming default being false, but SaveResponseInfo never reset the field, so a stale timing_allow_passed = true on the head could persist onto a filtered cross-origin (kCors/kOpaque) response that had no valid Timing-Allow-Origin match. The regression test SaveResponseInfo_TimingAllowPassedReset makes this concrete: it sets the head’s flag to true, calls SaveResponseInfo on a kOpaque response, and asserts the flag comes back false.
The fix forces out_head->timing_allow_passed = false; in SaveResponseInfo, re-establishing a safe default, while StartResponse re-grants true only through the explicit response-type or TAO checks.
StartResponse replaced an unconditional assignment with branches that only ever set timing_allow_passed = true, silently making correctness depend on the flag defaulting to false; the core fix is resetting timing_allow_passed = false in SaveResponseInfo so a stale true cannot survive onto a filtered cross-origin response that failed the Timing-Allow check.Attack Path
- Control the document with a service worker An attacker-controlled origin registers a service worker that intercepts the page’s subresource fetches.
- Serve a filtered cross-origin response
The worker responds to a subresource request with a filtered response (
kCorsorkOpaque) for a cross-origin target that provides no validTiming-Allow-Originheader matchingresource_request_.request_initiator. - Carry a stale allow flag
Because
SaveResponseInfodid not cleartiming_allow_passed, aURLResponseHeadalready bearingtiming_allow_passed = truekeeps that value, andStartResponse’s branches never set it back tofalseon the filtered fall-through path. - Read cross-origin timing
The document reads Resource Timing /
Server-Timingdata (inferred: via the Performance Timeline APIs) for the cross-origin resource that should have been redacted, disclosing timing information the target server never opted into exposing.
Impact Assessment
Server-Timing header values — for filtered (kCors/kOpaque) subresources whose servers never granted access via Timing-Allow-Origin. The precondition is a service worker controlling the document and intercepting the subresource fetch. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any use of the leaked timing for further cross-origin inference is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/renderer/service_worker/service_worker_subresource_loader_unittest.cc |
modified | |
FakeServiceWorkerContainerHostcontent/renderer/service_worker/service_worker_subresource_loader_unittest.cc |
modified | |
TEST_Fcontent/renderer/service_worker/service_worker_subresource_loader_unittest.cc |
modified | |
forcontent/renderer/service_worker/service_worker_subresource_loader_unittest.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/web_tests/external/wpt/service-workers/service-worker/resources/server-timing.pycontent/renderer/service_worker/service_worker_subresource_loader.ccthird_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin-server-timing.https.htmlcontent/renderer/service_worker/service_worker_subresource_loader_unittest.ccthird_party/blink/common/service_worker/service_worker_loader_helpers_unittest.cc
Audit Directions
- Assignment-to-conditional refactors on security flagsReview checks that were converted from an unconditional assignment to branches that only ever set the permissive value; confirm the non-matching path reaches a safe default rather than leaving a stale value.
- Uninitialized/uncleared fields in response-copy helpersAudit functions like
SaveResponseInfothat assembleURLResponseHeadfrom another response object for security-relevant fields (timing_allow_passed, CORS/TAO state) that are copied inconsistently or not reset. - Filtered-response timing exposure across loadersCompare
ServiceWorkerSubresourceLoaderwithServiceWorkerMainResourceLoaderand other loaders to ensurekCors/kOpaqueresponses gate Resource Timing andServer-Timingdisclosure on a realTimingAllowOriginCheckagainst the request initiator.
Patch
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/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);
}
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(
Regression Test / PoC
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(
Original Bug Report
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.ccthird_party/blink/renderer/core/fetch/fetch_response_data.ccthird_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.
- An attacker-controlled page registers a Service Worker that listens for fetch events.
- The Service Worker fetch event listener intercepts a same-origin subresource request (e.g.,
/probe) and responds with a cross-originno-corsfetch to a sensitive victim endpoint:event.respondWith(fetch('https://victim.example/api', {mode: 'no-cors', credentials: 'include'})); - The page initiates the load of
/probe(e.g., via<img src="/probe">orfetch('/probe')). - The loader process handles the request through
ServiceWorkerSubresourceLoader, which overridestiming_allow_passedtotrueand delivers the unfiltered headers to the page-sideResourceResponse. - 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.