From 0e4a09032746591c2fb0414d25941fd52cb2cadb Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Tue, 10 Apr 2018 08:01:54 +0100 Subject: [PATCH] Testing "attack 4" --- fetch/privileged-headers/resources/partial.py | 19 --- fetch/privileged-headers/sw.https.window.js | 54 -------- .../general.any.js | 19 ++- fetch/range/partial-script.window.js | 7 ++ .../resources/basic.html | 0 .../resources/long-wav.py | 23 ++-- fetch/range/resources/partial-script.py | 30 +++++ .../resources/range-sw.js | 48 +++++-- .../resources/stash-take.py | 0 fetch/range/resources/utils.js | 9 ++ fetch/range/sw.https.window.js | 119 ++++++++++++++++++ 11 files changed, 227 insertions(+), 101 deletions(-) delete mode 100644 fetch/privileged-headers/resources/partial.py delete mode 100644 fetch/privileged-headers/sw.https.window.js rename fetch/{privileged-headers => range}/general.any.js (71%) create mode 100644 fetch/range/partial-script.window.js rename fetch/{privileged-headers => range}/resources/basic.html (100%) rename fetch/{privileged-headers => range}/resources/long-wav.py (79%) create mode 100644 fetch/range/resources/partial-script.py rename fetch/{privileged-headers => range}/resources/range-sw.js (80%) rename fetch/{privileged-headers => range}/resources/stash-take.py (100%) create mode 100644 fetch/range/resources/utils.js create mode 100644 fetch/range/sw.https.window.js diff --git a/fetch/privileged-headers/resources/partial.py b/fetch/privileged-headers/resources/partial.py deleted file mode 100644 index 0f31b193e556eba..000000000000000 --- a/fetch/privileged-headers/resources/partial.py +++ /dev/null @@ -1,19 +0,0 @@ -def main(_, response): - response.headers.set("Content-Type", "application/javascript") - response.headers.set("Accept-Ranges", "bytes") - response.headers.set("Cache-Control", "no-cache") - response.status = 206 - content = 'window.scriptExecuted = true' - content_length = len(content) - pretend_offset = 5000 - pretend_total = 10000 - - content_range = "bytes {}-{}/{}".format( - pretend_offset, - pretend_offset + content_length - 1, pretend_total - ) - - response.headers.set("Content-Range", content_range) - response.headers.set("Content-Length", content_length) - - return content diff --git a/fetch/privileged-headers/sw.https.window.js b/fetch/privileged-headers/sw.https.window.js deleted file mode 100644 index 24a5ca719fe8d02..000000000000000 --- a/fetch/privileged-headers/sw.https.window.js +++ /dev/null @@ -1,54 +0,0 @@ -// META: script=../../../service-workers/service-worker/resources/test-helpers.sub.js -// META: script=/common/utils.js - -const SCOPE = 'resources/basic.html'; - -async function cleanup() { - for (const iframe of document.querySelectorAll('.test-iframe')) { - iframe.parentNode.removeChild(iframe); - } - - const reg = await navigator.serviceWorker.getRegistration(SCOPE); - if (reg) await reg.unregister(); -} - -async function setupRegistration(t) { - await cleanup(); - const reg = await navigator.serviceWorker.register('resources/range-sw.js', { scope: SCOPE }); - await wait_for_state(t, reg.installing, 'activated'); - return reg; -} - -promise_test(async t => { - const reg = await setupRegistration(t); - const iframe = await with_iframe(SCOPE); - const w = iframe.contentWindow; - - // Trigger a range request using media - const url = new URL('long-wav.py?action=range-header-filter-test', w.location); - url.hostname = 'www.web-platform.test'; - const audio = w.document.createElement('audio'); - audio.muted = true; - audio.src = url; - audio.preload = true; - w.document.body.appendChild(audio); - - await fetch_tests_from_worker(reg.active); -}, `Defer range header filter tests to service worker`); - -promise_test(async t => { - const reg = await setupRegistration(t); - const iframe = await with_iframe(SCOPE); - const w = iframe.contentWindow; - - // Trigger a range request using media - const url = new URL('long-wav.py?action=range-header-passthrough-test&range-received-key=' + token(), w.location); - url.hostname = 'www.web-platform.test'; - const audio = w.document.createElement('audio'); - audio.muted = true; - audio.src = url; - audio.preload = true; - w.document.body.appendChild(audio); - - await fetch_tests_from_worker(reg.active); -}, `Defer range header passthrough tests to service worker`); diff --git a/fetch/privileged-headers/general.any.js b/fetch/range/general.any.js similarity index 71% rename from fetch/privileged-headers/general.any.js rename to fetch/range/general.any.js index 358f9bafe777834..ff9fe76c4eb7277 100644 --- a/fetch/privileged-headers/general.any.js +++ b/fetch/range/general.any.js @@ -1,26 +1,32 @@ +// Helpers that return headers objects with a particular guard function headersGuardNone(fill) { if (fill) return new Headers(fill); return new Headers(); } function headersGuardResponse(fill) { - if (fill) return new Response('', { headers: fill }).headers; - return new Response('').headers; + const opts = {}; + if (fill) opts.headers = fill; + return new Response('', opts).headers; } function headersGuardRequest(fill) { - if (fill) return new Request('./', { headers: fill }).headers; - return new Request('./').headers; + const opts = {}; + if (fill) opts.headers = fill; + return new Request('./', opts).headers; } function headersGuardRequestNoCors(fill) { - if (fill) return new Request('./', { headers: fill, mode: 'no-cors' }).headers; - return new Request('./', { mode: 'no-cors' }).headers; + const opts = { mode: 'no-cors' }; + if (fill) opts.headers = fill; + return new Request('./', opts).headers; } test(() => { // Setting range should work for these guards for (const createHeaders of [headersGuardNone, headersGuardResponse, headersGuardRequest]) { + // There are three ways to set headers. + // Filling, appending, and setting. Test each: let headers = createHeaders({ Range: 'foo' }); assert_equals(headers.get('Range'), 'foo'); @@ -45,3 +51,4 @@ test(() => { headers.set('Range', 'foo'); assert_false(headers.has('Range')); }, `Privileged header is allowed unless guard is request-no-cors`); + diff --git a/fetch/range/partial-script.window.js b/fetch/range/partial-script.window.js new file mode 100644 index 000000000000000..36b4bba31aa8bcb --- /dev/null +++ b/fetch/range/partial-script.window.js @@ -0,0 +1,7 @@ +// META: script=resources/utils.js + +// It's weird that browsers do this, but it should continue to work. +promise_test(async t => { + await loadScript('resources/partial-script.py?pretend-offset=90000'); + assert_true(self.scriptExecuted); +}, `Script executed from partial response`); diff --git a/fetch/privileged-headers/resources/basic.html b/fetch/range/resources/basic.html similarity index 100% rename from fetch/privileged-headers/resources/basic.html rename to fetch/range/resources/basic.html diff --git a/fetch/privileged-headers/resources/long-wav.py b/fetch/range/resources/long-wav.py similarity index 79% rename from fetch/privileged-headers/resources/long-wav.py rename to fetch/range/resources/long-wav.py index 35365f7921911fd..bdd4dc221dee40f 100644 --- a/fetch/privileged-headers/resources/long-wav.py +++ b/fetch/range/resources/long-wav.py @@ -53,15 +53,17 @@ def main(request, response): range_received_key = request.GET.first('range-received-key', '') if range_received_key and range_header: + # This is later collected using stash-take.py request.stash.put(range_received_key, 'range-header-received', '/fetch/privileged-headers/') + # Audio details sample_rate = 8000 bit_depth = 8 channels = 1 duration = 60 * 5 total_length = (sample_rate * bit_depth * channels * duration) / 8 - bytes_to_send = total_length + bytes_remaining_to_send = total_length initial_write = '' if range_header: @@ -72,17 +74,17 @@ def main(request, response): end = int(end) if end else 0 if end: - bytes_to_send = (end + 1) - start + bytes_remaining_to_send = (end + 1) - start else: - bytes_to_send = total_length - start + bytes_remaining_to_send = total_length - start wav_header = create_wav_header(sample_rate, bit_depth, channels, duration) if start < len(wav_header): initial_write = wav_header[start:] - if bytes_to_send < len(initial_write): - initial_write = initial_write[0:bytes_to_send] + if bytes_remaining_to_send < len(initial_write): + initial_write = initial_write[0:bytes_remaining_to_send] content_range = "bytes {}-{}/{}".format(start, end or total_length - 1, total_length) @@ -90,19 +92,20 @@ def main(request, response): else: initial_write = create_wav_header(sample_rate, bit_depth, channels, duration) - response.headers.set("Content-Length", bytes_to_send) + response.headers.set("Content-Length", bytes_remaining_to_send) response.write_status_headers() response.writer.write(initial_write) - bytes_to_send -= len(initial_write) + bytes_remaining_to_send -= len(initial_write) - while bytes_to_send > 0: + while bytes_remaining_to_send > 0: if not response.writer.flush(): break - to_send = b'\x00' * min(bytes_to_send, sample_rate) - bytes_to_send -= len(to_send) + to_send = b'\x00' * min(bytes_remaining_to_send, sample_rate) + bytes_remaining_to_send -= len(to_send) response.writer.write(to_send) + # Throttle the stream time.sleep(0.5) diff --git a/fetch/range/resources/partial-script.py b/fetch/range/resources/partial-script.py new file mode 100644 index 000000000000000..5b642c601d89dcd --- /dev/null +++ b/fetch/range/resources/partial-script.py @@ -0,0 +1,30 @@ +""" +This generates a partial response containing valid JavaScript. +""" + + +def main(request, response): + require_range = request.GET.first('require-range', '') + pretend_offset = int(request.GET.first('pretend-offset', '0')) + range_header = request.headers.get('Range', '') + + if require_range and not range_header: + response.set_error(412, "Range header required") + response.write() + return + + response.headers.set("Content-Type", "text/plain") + response.headers.set("Accept-Ranges", "bytes") + response.headers.set("Cache-Control", "no-cache") + response.status = 206 + + to_send = 'self.scriptExecuted = true;' + length = len(to_send) + + content_range = "bytes {}-{}/{}".format( + pretend_offset, pretend_offset + length - 1, pretend_offset + length) + + response.headers.set("Content-Range", content_range) + response.headers.set("Content-Length", length) + + response.content = to_send diff --git a/fetch/privileged-headers/resources/range-sw.js b/fetch/range/resources/range-sw.js similarity index 80% rename from fetch/privileged-headers/resources/range-sw.js rename to fetch/range/resources/range-sw.js index 48dec8a14d830ef..4ae01b3651d7986 100644 --- a/fetch/privileged-headers/resources/range-sw.js +++ b/fetch/range/resources/range-sw.js @@ -6,6 +6,12 @@ function assert_range_request(request, expectedRangeHeader, name) { assert_equals(request.headers.get('Range'), expectedRangeHeader, name); } +async function broadcast(msg) { + for (const client of await clients.matchAll()) { + client.postMessage(msg); + } +} + addEventListener('fetch', event => { /** @type Request */ const request = event.request; @@ -19,26 +25,26 @@ addEventListener('fetch', event => { case 'range-header-passthrough-test': rangeHeaderPassthroughTest(event); return; + case 'store-ranged-response': + storeRangedResponse(event); + return; + case 'use-stored-ranged-response': + useStoredRangeResponse(event); + return; } }); -let gotRangeResponse = false; - /** * @param {Request} request */ function rangeHeaderFilterTest(request) { - if (!request.headers.has('Range') || gotRangeResponse) return; - // Avoid running the test twice - gotRangeResponse = true; - const rangeValue = request.headers.get('Range'); test(() => { assert_range_request(new Request(request), rangeValue, `Untampered`); assert_range_request(new Request(request, {}), rangeValue, `Untampered (no init props set)`); assert_range_request(new Request(request, { __foo: 'bar' }), rangeValue, `Untampered (only invalid props set)`); - assert_range_request(new Request(request, { move: 'cors' }), rangeValue, `More permissive mode`); + assert_range_request(new Request(request, { mode: 'cors' }), rangeValue, `More permissive mode`); assert_range_request(request.clone(), rangeValue, `Clone`); }, "Range headers correctly preserved"); @@ -54,7 +60,7 @@ function rangeHeaderFilterTest(request) { headers = new Request(request).headers; headers.delete('does-not-exist'); - assert_equals(headers.get('Range'), rangeValue, `Preserved if no header removed`); + assert_equals(headers.get('Range'), rangeValue, `Preserved if no header actually removed`); headers = new Request(request).headers; headers.append('foo', 'bar'); @@ -98,10 +104,6 @@ function rangeHeaderPassthroughTest(event) { const url = new URL(request.url); const key = url.searchParams.get('range-received-key'); - if (!request.headers.has('Range') || gotRangeResponse) return; - // Avoid running the test twice - gotRangeResponse = true; - let waitUntilResolve; const waitUntilPromise = new Promise(r => waitUntilResolve = r); event.waitUntil(waitUntilPromise); @@ -118,3 +120,25 @@ function rangeHeaderPassthroughTest(event) { done(); } + +let storedRangeResponseP; + +function storeRangedResponse(event) { + /** @type Request */ + const request = event.request; + const id = new URL(request.url).searchParams.get('id'); + + storedRangeResponseP = fetch(event.request); + broadcast({id}); + + // Just send back any response, it isn't important for the test. + event.respondWith(new Response('')); +} + +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/privileged-headers/resources/stash-take.py b/fetch/range/resources/stash-take.py similarity index 100% rename from fetch/privileged-headers/resources/stash-take.py rename to fetch/range/resources/stash-take.py diff --git a/fetch/range/resources/utils.js b/fetch/range/resources/utils.js new file mode 100644 index 000000000000000..567f231d030aa53 --- /dev/null +++ b/fetch/range/resources/utils.js @@ -0,0 +1,9 @@ +function loadScript(url, { doc = document }={}) { + return new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.onload = () => resolve(); + script.onerror = () => reject(Error("Script load failed")); + script.src = url; + doc.body.appendChild(script); + }) +} diff --git a/fetch/range/sw.https.window.js b/fetch/range/sw.https.window.js new file mode 100644 index 000000000000000..eaa87f10e0cd350 --- /dev/null +++ b/fetch/range/sw.https.window.js @@ -0,0 +1,119 @@ +// META: script=../../../service-workers/service-worker/resources/test-helpers.sub.js +// META: script=/common/utils.js +// META: script=resources/utils.js + +const SCOPE = 'resources/basic.html'; + +function appendAudio(document, url) { + const audio = document.createElement('audio'); + audio.muted = true; + audio.src = url; + audio.preload = true; + document.body.appendChild(audio); +} + +async function cleanup() { + for (const iframe of document.querySelectorAll('.test-iframe')) { + iframe.parentNode.removeChild(iframe); + } + + const reg = await navigator.serviceWorker.getRegistration(SCOPE); + if (reg) await reg.unregister(); +} + +async function setupRegistration(t) { + await cleanup(); + const reg = await navigator.serviceWorker.register('resources/range-sw.js', { scope: 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(); + }); + }); +} + +promise_test(async t => { + const reg = await setupRegistration(t); + const iframe = await with_iframe(SCOPE); + const w = iframe.contentWindow; + + // Trigger a range request using media + const url = new URL('long-wav.py?action=range-header-filter-test', w.location); + url.hostname = 'www.web-platform.test'; + appendAudio(w.document, url); + + // See rangeHeaderFilterTest in resources/range-sw.js + await fetch_tests_from_worker(reg.active); +}, `Defer range header filter tests to service worker`); + +promise_test(async t => { + const reg = await setupRegistration(t); + const iframe = await with_iframe(SCOPE); + const w = iframe.contentWindow; + + // Trigger a range request using media + const url = new URL('long-wav.py?action=range-header-passthrough-test&range-received-key=' + token(), w.location); + url.hostname = 'www.web-platform.test'; + appendAudio(w.document, url); + + await fetch_tests_from_worker(reg.active); +}, `Defer range header passthrough tests to service worker`); + +promise_test(async t => { + const reg = await setupRegistration(t); + const iframe = await with_iframe(SCOPE); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + // Trigger a range request using media + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('action', 'store-ranged-response'); + url.searchParams.set('id', id); + url.hostname = 'www.web-platform.test'; + + appendAudio(w.document, url); + + await storedRangeResponse; + + // This should not throw + await w.fetch('?action=use-stored-ranged-response', { mode: 'no-cors' }); + + // This shouldn't throw either + await loadScript('?action=use-stored-ranged-response', { doc: w.document }); + + assert_false(!!w.scriptExecuted, `Partial response shouldn't be executed`); +}, `Ranged response not executed following no-cors ranged request`); + +promise_test(async t => { + const reg = await setupRegistration(t); + const iframe = await with_iframe(SCOPE); + const w = iframe.contentWindow; + const id = Math.random() + ''; + const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id); + + // Trigger a range request using media + const url = new URL('partial-script.py', w.location); + url.searchParams.set('require-range', '1'); + url.searchParams.set('action', 'store-ranged-response'); + url.searchParams.set('id', id); + + appendAudio(w.document, url); + + await storedRangeResponse; + + // This should not throw + await w.fetch('?action=use-stored-ranged-response'); + + // This shouldn't throw either + await loadScript('?action=use-stored-ranged-response', { doc: w.document }); + + assert_true(w.scriptExecuted, `Partial response should be executed`); +}, `Non-opaque ranged response executed`);