From a7dea2cac3215f9abebd01beee22055a7aca64a0 Mon Sep 17 00:00:00 2001 From: Yoav Weiss Date: Sun, 21 Jun 2026 11:31:29 +0300 Subject: [PATCH] Navigation Timing - destination based TAO --- navigation-timing/redirect-tao.tentative.html | 50 +++++++++++++ .../resources/redirect-tao-helper.js | 71 +++++++++++++++++++ navigation-timing/resources/redirect-tao.py | 22 ++++++ 3 files changed, 143 insertions(+) create mode 100644 navigation-timing/redirect-tao.tentative.html create mode 100644 navigation-timing/resources/redirect-tao-helper.js create mode 100644 navigation-timing/resources/redirect-tao.py diff --git a/navigation-timing/redirect-tao.tentative.html b/navigation-timing/redirect-tao.tentative.html new file mode 100644 index 00000000000000..d33f5af83e9f0b --- /dev/null +++ b/navigation-timing/redirect-tao.tentative.html @@ -0,0 +1,50 @@ + + +Navigation Timing: redirect timing exposure for cross-origin redirect chains based on Timing-Allow-Origin and the destination origin + + + + + + + + + + diff --git a/navigation-timing/resources/redirect-tao-helper.js b/navigation-timing/resources/redirect-tao-helper.js new file mode 100644 index 00000000000000..642ca72ff11de1 --- /dev/null +++ b/navigation-timing/resources/redirect-tao-helper.js @@ -0,0 +1,71 @@ +// Helpers for navigation redirect-timing TAO tests. +// +// These build a cross-origin server-side redirect chain (served from the "www" +// subdomain, which is cross-origin to the test page) that finally lands back on +// the test page's own origin -- the navigation's "destination origin". Each hop +// is handled by resources/redirect-tao.py, which optionally emits a +// Timing-Allow-Origin header. +// +// See: +// https://github.com/whatwg/fetch/pull/1931 +// https://github.com/whatwg/html/pull/12513 + +// The navigation's destination origin (where the chain lands). +const DESTINATION_ORIGIN = location.origin; + +// The final, non-redirect document the chain resolves to (same-origin with the +// test page, so its navigation timing entry is readable). +const FINAL_URL = + make_absolute_url({path: "/navigation-timing/resources/blank-page-green.html"}); + +// Builds a redirect-chain URL from `hops`, an array with one entry per redirect. +// Each entry is the value to send in that redirect's Timing-Allow-Origin header, +// or null to send no header (i.e. that hop does not opt in). +function redirect_chain_url(hops) { + let url = FINAL_URL; + // Build from the last hop backwards, so each redirect points at the next one. + for (let i = hops.length - 1; i >= 0; i--) { + const tao = hops[i] === null ? "" : "tao=" + encodeURIComponent(hops[i]) + "&"; + url = make_absolute_url({ + subdomain: "www", + path: "/navigation-timing/resources/redirect-tao.py", + query: tao + "location=" + encodeURIComponent(url), + }); + } + return url; +} + +// Navigates an iframe through the redirect chain described by `hops` and resolves +// with the iframe's PerformanceNavigationTiming entry. `referrerPolicy` is an +// optional referrer policy to apply to the iframe (e.g. "no-referrer"). +function navigation_entry_after_redirects(hops, {referrerPolicy} = {}) { + return new Promise(resolve => { + const frame = document.createElement("iframe"); + frame.style.cssText = "width: 250px; height: 250px;"; + if (referrerPolicy) { + frame.referrerPolicy = referrerPolicy; + } + frame.onload = () => { + resolve(frame.contentWindow.performance.getEntriesByType("navigation")[0]); + }; + frame.src = redirect_chain_url(hops); + document.body.appendChild(frame); + }); +} + +// Asserts that redirect timing is exposed, with `expectedCount` redirects. +function assert_redirect_timing_exposed(entry, expectedCount) { + assert_equals(entry.type, "navigate", "navigation type"); + assert_equals(entry.redirectCount, expectedCount, "redirectCount"); + assert_greater_than(entry.redirectStart, 0, "redirectStart is exposed"); + assert_greater_than_equal(entry.redirectEnd, entry.redirectStart, + "redirectEnd is greater than or equal to redirectStart"); +} + +// Asserts that redirect timing is fully hidden (zeroed out). +function assert_redirect_timing_hidden(entry) { + assert_equals(entry.type, "navigate", "navigation type"); + assert_equals(entry.redirectCount, 0, "redirectCount is hidden"); + assert_equals(entry.redirectStart, 0, "redirectStart is hidden"); + assert_equals(entry.redirectEnd, 0, "redirectEnd is hidden"); +} diff --git a/navigation-timing/resources/redirect-tao.py b/navigation-timing/resources/redirect-tao.py new file mode 100644 index 00000000000000..021acfc7c3261d --- /dev/null +++ b/navigation-timing/resources/redirect-tao.py @@ -0,0 +1,22 @@ +def main(request, response): + """Redirect handler that optionally sets a Timing-Allow-Origin header. + + Query parameters: + status - The status code to use for the redirection. Defaults to 302. + location - The (percent-encoded) resource to redirect to. + tao - The value to send in the Timing-Allow-Origin response header. If + absent, no Timing-Allow-Origin header is sent (i.e. the redirect + does not opt in). + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + location = request.GET.first(b"location") + response.headers.set(b"Location", location) + if b"tao" in request.GET: + response.headers.set(b"Timing-Allow-Origin", request.GET.first(b"tao"))