CVE-2026-3929
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc |
modified |
Files Changed
third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.ccthird_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.htmlthird_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html
Patch
From 88392b548909e77969f21eeab3249d87e8164509 Mon Sep 17 00:00:00 2001 From: Noam Rosenthal <[email protected]> Date: Mon, 02 Feb 2026 03:48:35 -0800 Subject: [PATCH] Clear connection timing when service-worker response is passed to resource timing. By spec, this information is part of the fetch rather than the response, and the connection info for a response passed from a service worker is not the connection info of the client's fetch. Bug: 477180001 Change-Id: I98145f0976eb8d301a21c20e3c7892a96edd9274 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7516608 Commit-Queue: Noam Rosenthal <[email protected]> Reviewed-by: Yoav Weiss (@Shopify) <[email protected]> Cr-Commit-Position: refs/heads/main@{#1578031} --- diff --git a/third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc b/third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc index 5685842..eaf288f 100644 --- a/third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc +++ b/third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc @@ -59,14 +59,6 @@ info->server_timing = ParseServerTimingFromHeaderValueToMojo( response->HttpHeaderField(http_names::kServerTiming)); info->cache_state = response->CacheState(); - info->alpn_negotiated_protocol = response->AlpnNegotiatedProtocol().IsNull() - ? g_empty_string - : response->AlpnNegotiatedProtocol(); - info->connection_info = response->ConnectionInfoString().IsNull() - ? g_empty_string - : response->ConnectionInfoString(); - - info->did_reuse_connection = response->ConnectionReused(); // Use SecurityOrigin::Create to handle cases like blob:https://. info->is_secure_transport = std::ranges::contains( url::GetSecureSchemes(), @@ -74,6 +66,31 @@ info->timing = response->GetResourceLoadTiming() ? response->GetResourceLoadTiming()->ToMojo() : nullptr; + + if (response->WasFetchedViaServiceWorker()) { + // We don't forward connection info to the service worker's client. + // This information is available in the service worker's own performance + // timeline. + // Per-spec, the fetch-timing-info + // (https://fetch.spec.whatwg.org/#fetch-timing-info) is associated with + // the *fetch* and not attached to a response. + if (info->timing) { + info->timing->connect_timing = + network::mojom::blink::LoadTimingInfoConnectTiming::New(); + } + info->alpn_negotiated_protocol = g_empty_string; + info->connection_info = g_empty_string; + } else { + info->alpn_negotiated_protocol = + response->AlpnNegotiatedProtocol().IsNull() + ? g_empty_string + : response->AlpnNegotiatedProtocol(); + info->connection_info = response->ConnectionInfoString().IsNull() + ? g_empty_string + : response->ConnectionInfoString(); + + info->did_reuse_connection = response->ConnectionReused(); + } } else { // [spec] https://fetch.spec.whatwg.org/#create-an-opaque-timing-info diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html index 7a90743..0805db9 100644 --- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html +++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html @@ -31,9 +31,9 @@ assert_equals(await getNextHopProtocol(frame, `${base_url}?ignore`), expected_protocol, 'nextHopProtocol is set on fallback'); assert_equals(await getNextHopProtocol(frame, `${base_url}`), - expected_protocol, 'nextHopProtocol is set on pass-through'); + '', 'nextHopProtocol is not set on pass-through'); assert_equals(await getNextHopProtocol(frame, `${base_url}?cache`), - expected_protocol, 'nextHopProtocol is set on cached response'); + '', 'nextHopProtocol is not set on cached response'); } promise_test(async (t) => { diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html index 2155d7f..ca93a56 100644 --- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html +++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html @@ -29,9 +29,10 @@ assert_equals(entry.domainLookupEnd, entry.fetchStart, 'domainLookupEnd should be 0 in cross-origin request.'); assert_equals(entry.connectStart, entry.fetchStart, 'connectStart should be 0 in cross-origin request.'); assert_equals(entry.connectEnd, entry.fetchStart, 'connectEnd should be 0 in cross-origin request.'); - assert_greater_than(entry.responseStart, entry.fetchStart, 'responseStart should be 0 in cross-origin request.'); + assert_greater_than(entry.responseStart, entry.fetchStart, 'responseStart is specific to service-workers.'); assert_equals(entry.secureConnectionStart, entry.fetchStart, 'secureConnectionStart should be 0 in cross-origin request.'); - assert_equals(entry.transferSize, 0, 'decodedBodySize should be 0 in cross-origin request.'); + assert_equals(entry.transferSize, 0, 'transferSize should be 0 in cross-origin request.'); + assert_equals(entry.nextHopProtocol, "", 'nextHopProtocol should be 0 in cross-origin request.'); frame.remove(); await registration.unregister(); }, `Test that timing allow check fails when service worker changes origin from same to cross origin (${mode}).`);
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html
index 7a90743..0805db9 100644
--- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html
+++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html
@@ -31,9 +31,9 @@
assert_equals(await getNextHopProtocol(frame, `${base_url}?ignore`),
expected_protocol, 'nextHopProtocol is set on fallback');
assert_equals(await getNextHopProtocol(frame, `${base_url}`),
- expected_protocol, 'nextHopProtocol is set on pass-through');
+ '', 'nextHopProtocol is not set on pass-through');
assert_equals(await getNextHopProtocol(frame, `${base_url}?cache`),
- expected_protocol, 'nextHopProtocol is set on cached response');
+ '', 'nextHopProtocol is not set on cached response');
}
promise_test(async (t) => {
diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html
index 2155d7f..ca93a56 100644
--- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html
+++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html
@@ -29,9 +29,10 @@
assert_equals(entry.domainLookupEnd, entry.fetchStart, 'domainLookupEnd should be 0 in cross-origin request.');
assert_equals(entry.connectStart, entry.fetchStart, 'connectStart should be 0 in cross-origin request.');
assert_equals(entry.connectEnd, entry.fetchStart, 'connectEnd should be 0 in cross-origin request.');
- assert_greater_than(entry.responseStart, entry.fetchStart, 'responseStart should be 0 in cross-origin request.');
+ assert_greater_than(entry.responseStart, entry.fetchStart, 'responseStart is specific to service-workers.');
assert_equals(entry.secureConnectionStart, entry.fetchStart, 'secureConnectionStart should be 0 in cross-origin request.');
- assert_equals(entry.transferSize, 0, 'decodedBodySize should be 0 in cross-origin request.');
+ assert_equals(entry.transferSize, 0, 'transferSize should be 0 in cross-origin request.');
+ assert_equals(entry.nextHopProtocol, "", 'nextHopProtocol should be 0 in cross-origin request.');
frame.remove();
await registration.unregister();
}, `Test that timing allow check fails when service worker changes origin from same to cross origin (${mode}).`);
Original Bug Report
Service Worker subresource responses expose detailed resource timing information for cross-origin resources that are normally restricted, leading to an information leak in the Resource Timing API
Report description
Service Worker subresource responses expose detailed resource timing information for cross-origin resources that are normally restricted, leading to an information leak in the Resource Timing API
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://chromium.googlesource.com/chromium/src/
The problem
Please describe the technical details of the vulnerability
Chromium exposes detailed network timing and size information for resources via the Resource Timing API (performance.getEntriesByType("resource")). To protect cross-origin privacy, these details are only supposed to be available when the response satisfies the Timing-Allow-Origin (TAO) policy or is same-origin with the calling context.
For subresources intercepted by a Service Worker, the renderer-side subresource loader assumes that constructed responses are always same-origin with the client and unconditionally marks them as passing the timing allow check, regardless of the actual response origin or TAO headers.
In ServiceWorkerSubresourceLoader::StartResponse the URL loader head is filled from the Service Worker FetchAPIResponse, and then timing_allow_passed is set to true for all constructed subresource responses:
// chromium/src/content/renderer/service_worker/service_worker_subresource_loader.cc
void ServiceWorkerSubresourceLoader::StartResponse(
blink::mojom::FetchAPIResponsePtr response,
blink::mojom::ServiceWorkerStreamHandlePtr body_as_stream) {
// ...
blink::ServiceWorkerLoaderHelpers::SaveResponseInfo(*response,
response_head_.get());
response_head_->response_start = base::TimeTicks::Now();
response_head_->load_timing.receive_headers_start = base::TimeTicks::Now();
response_head_->load_timing.receive_headers_end =
response_head_->load_timing.receive_headers_start;
response_source_ = response->response_source;
// Constructed subresource responses are always same-origin as the requesting
// client.
response_head_->timing_allow_passed = true;
// ...
}
By contrast, the normal network loader path computes timing_allow_passed based on an explicit timing-allow-origin check:
// chromium/src/services/network/cors/cors_url_loader.cc
// (simplified)
timing_allow_failed_flag_ = !PassesTimingAllowOriginCheck(*response_head);
response_head->timing_allow_passed = !timing_allow_failed_flag_;
On the Blink side, this timing_allow_passed flag directly controls whether detailed timing and connection metadata are exposed to JavaScript. In CreateResourceTimingInfo, if TimingAllowPassed() is true, the allow_timing_details flag is set and the full set of fields are populated:
// chromium/src/third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc
mojom::blink::ResourceTimingInfoPtr CreateResourceTimingInfo(
base::TimeTicks start_time,
const KURL& initial_url,
const ResourceResponse* response) {
mojom::blink::ResourceTimingInfoPtr info =
mojom::blink::ResourceTimingInfo::New();
info->start_time = start_time;
info->name = initial_url;
info->response_end = base::TimeTicks::Now();
if (!response) {
return info;
}
if (response->TimingAllowPassed()) {
info->allow_timing_details = true;
info->server_timing = ParseServerTimingFromHeaderValueToMojo(
response->HttpHeaderField(http_names::kServerTiming));
info->cache_state = response->CacheState();
info->alpn_negotiated_protocol = response->AlpnNegotiatedProtocol().IsNull()
? g_empty_string
: response->AlpnNegotiatedProtocol();
info->connection_info = response->ConnectionInfoString().IsNull()
? g_empty_string
: response->ConnectionInfoString();
info->did_reuse_connection = response->ConnectionReused();
// Use SecurityOrigin::Create to handle cases like blob:https://.
info->is_secure_transport = base::Contains(
url::GetSecureSchemes(),
SecurityOrigin::Create(response->ResponseUrl())->Protocol().Ascii());
info->timing = response->GetResourceLoadTiming()
? response->GetResourceLoadTiming()->ToMojo()
: nullptr;
} else {
// Only limited timing fields are exposed when the timing allow check fails.
// ...
}
// ...
}
ResourceResponse explicitly documents that the response URL may differ from the request URL when a Service Worker responds with a different underlying resource:
// chromium/src/third_party/blink/renderer/platform/loader/fetch/resource_response.h
// ...
// Specifically, if a service worker responded to the request for this
// resource, it may have fetched an entirely different URL and responded with
// that resource. WasFetchedViaServiceWorker() and ResponseUrl() can be used
// to determine whether and how a service worker responded to the request.
// Example service worker code:
//
// onfetch = (event => {
// if (event.request.url == 'https://abc.com')
// event.respondWith(fetch('https://def.com'));
// });
//
// If this service worker responds to an "https://abc.com" request, then for
// the resulting ResourceResponse, CurrentRequestUrl() is "https://abc.com",
// WasFetchedViaServiceWorker() is true, and ResponseUrl() is
// "https://def.com".
const KURL& CurrentRequestUrl() const;
void SetCurrentRequestUrl(const KURL&);
// ...
KURL ResponseUrl() const;
The test code under web/fetch_timing/ sets up exactly this pattern:
- An HTML page served from one origin, for example
http://localhost:8080/index.html, registers a Service Worker whose scope covers/proxy. - The page offers two actions:
- Load a resource directly from
http://localhost:9000/target.binvia an<img>tag (cross-origin request). - Load the same underlying resource via a same-origin
<img src="/proxy?...">, where the Service Worker fetcheshttp://localhost:9000/target.binwithmode: "no-cors"and returns the response.
- Load a resource directly from
- After each load, the page inspects
performance.getEntriesByType("resource")and logs key fields (responseStart,responseEnd,transferSize,encodedBodySize,decodedBodySize,nextHopProtocol) for the correspondingPerformanceResourceTimingentry.
In this setup:
-
The direct cross-origin load from
http://localhost:8080tohttp://localhost:9000/target.binproduces a timing entry where sensitive fields are cleared or zeroed, for example:responseStart: 0transferSize: 0encodedBodySize: 0decodedBodySize: 0nextHopProtocol: ""
-
The Service Worker–mediated load uses a same-origin URL such as
http://localhost:8080/proxy?...as the observable resource name, but the Service Worker internally fetches the cross-origin target fromhttp://localhost:9000. For this entry, the logged timing data includes detailed values, for example:- non-zero
responseStartandresponseEnd transferSizeandnextHopProtocolpopulated
- non-zero
This demonstrates that code running in the origin that controls the Service Worker can obtain detailed timing and size information about a resource whose actual origin is different and that does not opt in via Timing-Allow-Origin.
Impact analysis
Who can exploit the vulnerability:
- Any web origin that can register and control a Service Worker for its own pages can exploit this issue for subresource requests it initiates (for example, an attacker-controlled site with a Service Worker whose scope covers a
/proxypath).
What they gain when doing so:
- The attacking origin can obtain detailed timing and connection metadata (such as non-zero
responseStart/responseEnd,transferSize,encodedBodySize,decodedBodySize, andnextHopProtocol) for resources that are actually fetched from a different origin and would normally have these fields restricted by the Resource Timing API. - This additional information can be used to:
- perform cross-site performance and availability probing of arbitrary URLs reachable from the browser;
- infer limited aspects of user or server state from response timing and size patterns (for example, presence of particular resources or cache behavior);
- strengthen cross-origin device or network fingerprinting by incorporating timing and protocol characteristics of external resources.
The cause
What version of Chrome have you found the security issue in?
145.0.7632.1/stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak
How would you like to be publicly acknowledged for your report?
Povcfe of Tencent Security Xuanwu Lab
- http://localhost:8080
- http://localhost:8080/index.html
- http://localhost:8080/proxy
- http://localhost:9000
- http://localhost:9000/target.bin
- https://abc.com
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/
- https://def.com