CVE-2026-3929
Overview
Background
- Resource Timing
- the web-exposed
PerformanceResourceTimingAPI, which reports per-resource fields such asnextHopProtocol,connectStart/connectEnd,secureConnectionStartand connection reuse, built in Blink from theResourceTimingInfomojo struct. - Service-worker-provided response
- a response the page receives from its service worker’s
fetchhandler (pass-throughfetch(), a Cache API entry, or a synthetic response) rather than from its own network request;response->WasFetchedViaServiceWorker()identifies it. - Fetch timing info
- per the Fetch spec (
#fetch-timing-info), connection-level timing belongs to the fetch that made the network request, not to the response object; a response handed back by a service worker therefore does not carry the client’s own connection info. - Connection info fields
alpn_negotiated_protocol,connection_info,did_reuse_connection, andtiming->connect_timing(DNS/connect/TLS timestamps) inResourceTimingInfo.
Root Cause Analysis
In third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc, the code that fills ResourceTimingInfo from a ResourceResponse copied AlpnNegotiatedProtocol(), ConnectionInfoString() and ConnectionReused() from the response unconditionally. It also passed GetResourceLoadTiming()->ToMojo() through, including its connect timing. The diff does not name the enclosing function. When the response came from a service worker, those values described the service worker’s own fetch (or whatever the cached/stored response carried), not the client’s request. So the client page’s Resource Timing entries exposed connection metadata that the spec does not attach to responses. The updated WPT shows this. In resource-timing-cross-origin.https.html, a service worker turns a same-origin request into a cross-origin response, so the timing-allow check fails. The test now also asserts nextHopProtocol === "", which implies the protocol was previously exposed even in that failing-TAO case (inferred from the test change).
The fix adds a WasFetchedViaServiceWorker() branch. It clears connect_timing to an empty LoadTimingInfoConnectTiming, sets alpn_negotiated_protocol and connection_info to g_empty_string, and does not copy did_reuse_connection. The old copying moves into the else branch for non-service-worker responses.
WasFetchedViaServiceWorker() is true.Attack Path
- Service worker in scope the attacker’s page is controlled by a service worker, which is reachable from ordinary HTTPS web content.
- Return a foreign response
the service worker answers a client request with a response it obtained itself, for example a pass-through
fetch()(including one that switches to a cross-origin URL) or a Cache API entry, as in the WPT cases. - Read Resource Timing
the page reads
performance.getEntriesByType('resource')and getsnextHopProtocoland connection timing/reuse values copied from that response. - Infer network state because these values describe the service worker’s fetch, they can reveal connection details, for example protocol or whether a connection to another origin was reused, that the timing-allow check or spec would otherwise hide. The specific cross-origin or cross-site state inferred is (inferred).
Impact Assessment
nextHopProtocol, connection type, connect/TLS timings, connection reuse) for responses produced by a service worker, including cross-origin ones whose timing should be opaque. It needs only a registered service worker on an HTTPS origin in a stock build; no compromised renderer, flag or user gesture is shown to be required. Any use of this as a cross-site history or state side channel is (inferred). The vendor rates it medium severity (“Side-channel information leakage in ResourceTiming”).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
Audit Directions
- Response-carried vs. fetch-carried metadataaudit other places that copy per-connection or per-fetch data (e.g.
ConnectionReused,RemoteIPEndpoint,encoded_data_length/transferSize,response_start) from aResourceResponseinto web-exposed objects. Check service-worker, cache, prefetch and other reused-response paths where the response came from a different fetch. - TAO-failing branch completenessin the cross-origin “opaque timing info” path, check that every field (not only the timestamps the existing tests assert) is zeroed. The missing
nextHopProtocolassertion shows that some fields were not being checked. - Other response-forwarding pathslook at similar copying in Navigation Timing,
PerformanceNavigationTiming, and worker or shared-worker script loads served by a service worker. The same fetch-vs-response mix-up could show up there.
Patch
From 88392b548909e77969f21eeab3249d87e8164509 Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <nrosenthal@chromium.org>
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 <nrosenthal@google.com>
Reviewed-by: Yoav Weiss (@Shopify) <yoavweiss@chromium.org>
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