Skip to content

Commit

Permalink
Prevent opaque range request responses from entering the preload cache
Browse files Browse the repository at this point in the history
ResourceLoader cancels range request responses that were not initiated
with range request headers causing them to error out and be cleared from
the preload cache. Other responses (200, 416, error, etc) complete
successfully and would otherwise enter the preload cache, making them
observable.

This prevents opaque range responses of any kind from persisting in the
preload cache (which would not naturally have any anyway).

Bug: 1270990
Change-Id: Ife9922fe0b88e39722f3664ddd091a1516892157
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3294001
Reviewed-by: Ben Kelly <wanderview@chromium.org>
Reviewed-by: Yoav Weiss <yoavweiss@chromium.org>
Commit-Queue: Patrick Meenan <pmeenan@chromium.org>
Cr-Commit-Position: refs/heads/main@{#946055}
  • Loading branch information
pmeenan authored and chromium-wpt-export-bot committed Nov 29, 2021
1 parent 71a9256 commit 5fc81f8
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 1 deletion.
47 changes: 47 additions & 0 deletions fetch/range/resources/partial-text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
This generates a partial response for a 100-byte text file.
"""
import re

from wptserve.utils import isomorphic_decode

def main(request, response):
total_length = int(request.GET.first(b'length', b'100'))
partial_code = int(request.GET.first(b'partial', b'206'))
range_header = request.headers.get(b'Range', b'')

# Send a 200 if there is no range request
if not range_header:
to_send = ''.zfill(total_length)
response.headers.set(b"Content-Type", b"text/plain")
response.headers.set(b"Cache-Control", b"no-cache")
response.headers.set(b"Content-Length", total_length)
response.content = to_send
return

# Simple range parsing, requires specifically "bytes=xxx-xxxx"
range_header_match = re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header))
start, end = range_header_match.groups()
start = int(start)
end = int(end) if end else total_length
length = end - start

# Error the request if the range goes beyond the length
if length <= 0 or end > total_length:
response.set_error(416, u"Range Not Satisfiable")
response.write()
return

# Generate a partial response of the requested length
to_send = ''.zfill(length)
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 = partial_code

content_range = b"bytes %d-%d/%d" % (start, end, total_length)

response.headers.set(b"Content-Range", content_range)
response.headers.set(b"Content-Length", length)

response.content = to_send
61 changes: 60 additions & 1 deletion fetch/range/resources/range-sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async function broadcast(msg) {
}
}

addEventListener('fetch', event => {
addEventListener('fetch', async event => {
/** @type Request */
const request = event.request;
const url = new URL(request.url);
Expand All @@ -34,6 +34,11 @@ addEventListener('fetch', event => {
case 'broadcast-accept-encoding':
broadcastAcceptEncoding(event);
return;
case 'record-media-range-request':
return recordMediaRangeRequest(event);
case 'use-media-range-request':
useMediaRangeRequest(event);
return;
}
});

Expand Down Expand Up @@ -157,3 +162,57 @@ function broadcastAcceptEncoding(event) {
// Just send back any response, it isn't important for the test.
event.respondWith(new Response(''));
}

let rangeResponse = {};

async function recordMediaRangeRequest(event) {
/** @type Request */
const request = event.request;
const url = new URL(request.url);
const urlParams = new URLSearchParams(url.search);
const size = urlParams.get("size");
const id = urlParams.get('id');
const key = 'size' + size;

if (key in rangeResponse) {
// Don't re-fetch ranges we already have.
const clonedResponse = rangeResponse[key].clone();
event.respondWith(clonedResponse);
} else if (event.request.headers.get("range") === "bytes=0-") {
// Generate a bogus 206 response to trigger subsequent range requests
// of the desired size.
const length = urlParams.get("length") + 100;
const body = "A".repeat(Number(size));
event.respondWith(new Response(body, {status: 206, headers: {
"Content-Type": "audio/mp4",
"Content-Range": `bytes 0-1/${length}`
}}));
} else if (event.request.headers.get("range") === `bytes=${Number(size)}-`) {
// Pass through actual range requests which will attempt to fetch up to the
// length in the original response which is bigger than the actual resource
// to make sure 206 and 416 responses are treated the same.
rangeResponse[key] = await fetch(event.request);

// Let the client know we have the range response for the given ID
broadcast({id});
} else {
event.respondWith(Promise.reject(Error("Invalid Request")));
}
}

function useMediaRangeRequest(event) {
/** @type Request */
const request = event.request;
const url = new URL(request.url);
const urlParams = new URLSearchParams(url.search);
const size = urlParams.get("size");
const key = 'size' + size;

// Send a clone of the range response to preload.
if (key in rangeResponse) {
const clonedResponse = rangeResponse[key].clone();
event.respondWith(clonedResponse);
} else {
event.respondWith(Promise.reject(Error("Invalid Request")));
}
}
12 changes: 12 additions & 0 deletions fetch/range/resources/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ function loadScript(url, { doc = document }={}) {
})
}

function preloadImage(url, { doc = document }={}) {
return new Promise((resolve, reject) => {
const preload = doc.createElement('link');
preload.rel = 'preload';
preload.as = 'image';
preload.onload = () => resolve();
preload.onerror = () => resolve();
preload.href = url;
doc.body.appendChild(preload);
})
}

/**
*
* @param {Document} document
Expand Down
75 changes: 75 additions & 0 deletions fetch/range/sw.https.window.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,78 @@ promise_test(async t => {

assert_equals((await audioBroadcast).acceptEncoding, null, "Accept-Encoding should not be set for media");
}, `Accept-Encoding should not appear in a service worker`);

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 length = 100;
const count = 3;
const counts = {};

// test a single range request size
async function testSizedRange(size, partialResponseCode) {
const rangeId = Math.random() + '';
const rangeBroadcast = awaitMessage(w.navigator.serviceWorker, rangeId);

// Create a bogus audo element to trick the browser into sending
// cross-origin range requests that can be manipulated by the service worker.
const sound_url = new URL('partial-text.py', w.location);
sound_url.hostname = REMOTE_HOST;
sound_url.searchParams.set('action', 'record-media-range-request');
sound_url.searchParams.set('length', length);
sound_url.searchParams.set('size', size);
sound_url.searchParams.set('partial', partialResponseCode);
sound_url.searchParams.set('id', rangeId);
appendAudio(w.document, sound_url);

// wait for the range requests to happen
await rangeBroadcast;

// Create multiple preload requests and count the number of resource timing
// entries that get created to make sure 206 and 416 range responses are treated
// the same.
const url = new URL('partial-text.py', w.location);
url.searchParams.set('action', 'use-media-range-request');
url.searchParams.set('size', size);
counts['size' + size] = 0;
for (let i = 0; i < count; i++) {
await preloadImage(url, { doc: w.document });
}
}

// Test range requests from 1 smaller than the correct size to 1 larger than
// the correct size to exercise the various permutations using the default 206
// response code for successful range requests.
for (let size = length - 1; size <= length + 1; size++) {
await testSizedRange(size, '206');
}

// Test a successful range request using a 200 response.
await testSizedRange(length - 2, '200');

// Check the resource timing entries and count the reported number of fetches of each type
const resources = w.performance.getEntriesByType("resource");
for (const entry of resources) {
const url = new URL(entry.name);
if (url.searchParams.has('action') &&
url.searchParams.get('action') == 'use-media-range-request' &&
url.searchParams.has('size')) {
counts['size' + url.searchParams.get('size')]++;
}
}

// Make sure there are a non-zero number of preload requests and they are all the same
let counts_valid = true;
const first = 'size' + (length - 2);
for (let size = length - 2; size <= length + 1; size++) {
let key = 'size' + size;
if (!(key in counts) || counts[key] <= 0 || counts[key] != counts[first]) {
counts_valid = false;
break;
}
}

assert_true(counts_valid, `Opaque range request preloads were different for error and success`);
}, `Opaque range preload successes and failures should be indistinguishable`);

0 comments on commit 5fc81f8

Please sign in to comment.