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

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactSide-channel information leakage in ResourceTiming
DescriptionSide-channel information leakage in ResourceTiming
ComponentResourceTiming
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker477180001
Fix commit88392b548909 (chromium/src) +30/-12
CWECWE-1300
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedPovcfe of Tencent Security Xuanwu Lab
Reported2026-01-20 · bounty $2000 (per vendor advisory)
Fix landed2026-02-02 (public git)
Disclosed2026-03-10
Patch gap36 days — fix public in source before the advisory

Background

Resource Timing
the web-exposed PerformanceResourceTiming API, which reports per-resource fields such as nextHopProtocol, connectStart/connectEnd, secureConnectionStart and connection reuse, built in Blink from the ResourceTimingInfo mojo struct.
Service-worker-provided response
a response the page receives from its service worker’s fetch handler (pass-through fetch(), 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, and timing->connect_timing (DNS/connect/TLS timestamps) in ResourceTimingInfo.

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.

Key insight
Connection metadata (ALPN protocol, connection type, connect/TLS timing, reuse flag) was copied from service-worker-supplied responses into the client’s Resource Timing entry, although it describes a different fetch. The fix blanks these fields whenever WasFetchedViaServiceWorker() is true.

Attack Path

  1. Service worker in scope the attacker’s page is controlled by a service worker, which is reachable from ordinary HTTPS web content.
  2. 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.
  3. Read Resource Timing the page reads performance.getEntriesByType('resource') and gets nextHopProtocol and connection timing/reuse values copied from that response.
  4. 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

This is a renderer-side information leak to web content. A page can learn connection metadata (ALPN/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

FunctionChangeNotes
if
third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc
modified

Files Changed

  • third_party/blink/renderer/platform/loader/fetch/resource_timing_utils.cc
  • third_party/blink/web_tests/external/wpt/service-workers/service-worker/next-hop-protocol.https.html
  • third_party/blink/web_tests/external/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html

Audit Directions

  • Response-carried vs. fetch-carried metadata
    audit other places that copy per-connection or per-fetch data (e.g. ConnectionReused, RemoteIPEndpoint, encoded_data_length/transferSize, response_start) from a ResourceResponse into web-exposed objects. Check service-worker, cache, prefetch and other reused-response paths where the response came from a different fetch.
  • TAO-failing branch completeness
    in the cross-origin “opaque timing info” path, check that every field (not only the timestamps the existing tests assert) is zeroed. The missing nextHopProtocol assertion shows that some fields were not being checked.
  • Other response-forwarding paths
    look 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.
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}).`);
Loading diff…

Regression Test / PoC

shipped with the fix
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}).`);
Loading diff…

Original Bug Report

reported by po...@gmail.com

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.bin via an <img> tag (cross-origin request).
    • Load the same underlying resource via a same-origin <img src="/proxy?...">, where the Service Worker fetches http://localhost:9000/target.bin with mode: "no-cors" and returns the response.
  • After each load, the page inspects performance.getEntriesByType("resource") and logs key fields (responseStart, responseEnd, transferSize, encodedBodySize, decodedBodySize, nextHopProtocol) for the corresponding PerformanceResourceTiming entry.

In this setup:

  • The direct cross-origin load from http://localhost:8080 to http://localhost:9000/target.bin produces a timing entry where sensitive fields are cleared or zeroed, for example:

    • responseStart: 0
    • transferSize: 0
    • encodedBodySize: 0
    • decodedBodySize: 0
    • nextHopProtocol: ""
  • 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 from http://localhost:9000. For this entry, the logged timing data includes detailed values, for example:

    • non-zero responseStart and responseEnd
    • transferSize and nextHopProtocol populated

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 /proxy path).

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, and nextHopProtocol) 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

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

View on issue tracker