From 128f5aad708aaf5707f2fdabddc3cfbf97e381e5 Mon Sep 17 00:00:00 2001 From: Anne van Kesteren Date: Sat, 17 Jan 2026 11:11:46 +0100 Subject: [PATCH] Fetch: blocking opaque 416 responses to range requests See https://github.com/whatwg/fetch/pull/1907 for context. We use an image instead of JavaScript as JavaScript requires a 2xx status whereas images don't care. Also remove a redundant null check from useStoredRangeResponse() and a redundant catch() elsewhere. --- fetch/range/resources/partial-script.py | 32 +++++++++---- fetch/range/resources/range-sw.js | 1 - fetch/range/resources/utils.js | 10 ++++ fetch/range/sw-416.https.window.js | 61 +++++++++++++++++++++++++ fetch/range/sw.https.window.js | 2 - 5 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 fetch/range/sw-416.https.window.js diff --git a/fetch/range/resources/partial-script.py b/fetch/range/resources/partial-script.py index a9570ec355c63d..d0554485dc5bcb 100644 --- a/fetch/range/resources/partial-script.py +++ b/fetch/range/resources/partial-script.py @@ -1,10 +1,12 @@ """ -This generates a partial response containing valid JavaScript. +This generates a partial response containing valid JavaScript or image data. """ def main(request, response): require_range = request.GET.first(b'require-range', b'') pretend_offset = int(request.GET.first(b'pretend-offset', b'0')) + range_not_satisfiable = request.GET.first(b'range-not-satisfiable', b'') + content_type = request.GET.first(b'type', b'text/plain') range_header = request.headers.get(b'Range', b'') if require_range and not range_header: @@ -12,18 +14,28 @@ def main(request, response): response.write() return - response.headers.set(b"Content-Type", b"text/plain") - response.headers.set(b"Accept-Ranges", b"bytes") - response.headers.set(b"Cache-Control", b"no-cache") - response.status = 206 + # 1x1 red PNG image (67 bytes) + png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\x00\x18\xdd\x8d\xb4\x00\x00\x00\x00IEND\xaeB`\x82' - to_send = b'self.scriptExecuted = true;' - length = len(to_send) + if content_type == b'image/png': + to_send = png_data + else: + to_send = b'self.scriptExecuted = true;' - content_range = b"bytes %d-%d/%d" % ( - pretend_offset, pretend_offset + length - 1, pretend_offset + length) + length = len(to_send) - response.headers.set(b"Content-Range", content_range) + response.headers.set(b"Content-Type", content_type) + response.headers.set(b"Accept-Ranges", b"bytes") + response.headers.set(b"Cache-Control", b"no-cache") response.headers.set(b"Content-Length", length) + if range_not_satisfiable: + response.status = 416 + response.headers.set(b"Content-Range", b"bytes */%d" % (pretend_offset + length)) + else: + response.status = 206 + content_range = b"bytes %d-%d/%d" % ( + pretend_offset, pretend_offset + length - 1, pretend_offset + length) + response.headers.set(b"Content-Range", content_range) + response.content = to_send diff --git a/fetch/range/resources/range-sw.js b/fetch/range/resources/range-sw.js index b47823f03b4ef3..dfa537a20a58e5 100644 --- a/fetch/range/resources/range-sw.js +++ b/fetch/range/resources/range-sw.js @@ -144,7 +144,6 @@ function storeRangedResponse(event) { function useStoredRangeResponse(event) { event.respondWith(async function() { const response = await storedRangeResponseP; - if (!response) throw Error("Expected stored range response"); return response.clone(); }()); } diff --git a/fetch/range/resources/utils.js b/fetch/range/resources/utils.js index ad2853b33dc747..b41c41f7f6a02b 100644 --- a/fetch/range/resources/utils.js +++ b/fetch/range/resources/utils.js @@ -8,6 +8,16 @@ function loadScript(url, { doc = document }={}) { }) } +function loadImage(url, { doc = document }={}) { + return new Promise((resolve, reject) => { + const img = doc.createElement('img'); + img.onload = () => resolve(); + img.onerror = () => reject(Error("Image load failed")); + img.src = url; + doc.body.appendChild(img); + }) +} + function preloadImage(url, { doc = document }={}) { return new Promise((resolve, reject) => { const preload = doc.createElement('link'); diff --git a/fetch/range/sw-416.https.window.js b/fetch/range/sw-416.https.window.js new file mode 100644 index 00000000000000..ca4436a17fcc77 --- /dev/null +++ b/fetch/range/sw-416.https.window.js @@ -0,0 +1,61 @@ +// META: script=../../../service-workers/service-worker/resources/test-helpers.sub.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/utils.js + +const { REMOTE_HOST } = get_host_info(); +const BASE_SCOPE = 'resources/basic.html?'; + +async function cleanup() { + for (const iframe of document.querySelectorAll('.test-iframe')) { + iframe.parentNode.removeChild(iframe); + } + + for (const reg of await navigator.serviceWorker.getRegistrations()) { + await reg.unregister(); + } +} + +async function setupRegistration(t, scope) { + await cleanup(); + const reg = await navigator.serviceWorker.register('resources/range-sw.js', { scope }); + await wait_for_state(t, reg.installing, 'activated'); + return reg; +} + +function awaitMessage(obj, id) { + return new Promise(resolve => { + obj.addEventListener('message', function listener(event) { + if (event.data.id !== id) return; + obj.removeEventListener('message', listener); + resolve(event.data); + }); + }); +} + +promise_test(async t => { + const scope = BASE_SCOPE + Math.random(); + await setupRegistration(t, scope); + const iframe = await with_iframe(scope); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('range-not-satisfiable', '1'); + url.searchParams.set('type', 'image/png'); + url.searchParams.set('action', 'store-ranged-response'); + url.searchParams.set('id', id); + url.hostname = REMOTE_HOST; + + appendAudio(w.document, url); + + await storedRangeResponse; + + const fetchPromise = w.fetch('?action=use-stored-ranged-response', { mode: 'no-cors' }); + await promise_rejects_js(t, w.TypeError, fetchPromise); + + const loadImagePromise = loadImage('?action=use-stored-ranged-response', { doc: w.document }); + await promise_rejects_js(t, Error, loadImagePromise); +}, `416 response not allowed following no-cors ranged request`); diff --git a/fetch/range/sw.https.window.js b/fetch/range/sw.https.window.js index 62ad894da3d4d7..94c23f078d321c 100644 --- a/fetch/range/sw.https.window.js +++ b/fetch/range/sw.https.window.js @@ -92,8 +92,6 @@ promise_test(async t => { const loadScriptPromise = loadScript('?action=use-stored-ranged-response', { doc: w.document }); await promise_rejects_js(t, Error, loadScriptPromise); - await loadScriptPromise.catch(() => {}); - assert_false(!!w.scriptExecuted, `Partial response shouldn't be executed`); }, `Ranged response not allowed following no-cors ranged request`);