service worker: Add tests for canvas tainting from a video with range requests.
This tests that a canvas is tainted when the video had multiple responses
from a service worker, if any of the responses were opaque.
Bug: 780435
Change-Id: Ifef394c87921cb646b1728a9079908264673fdd4
Reviewed-on: https://chromium-review.googlesource.com/897165
Reviewed-by: Tsuyoshi Horo <horo@chromium.org>
Commit-Queue: Matt Falkenhagen <falken@chromium.org>
Cr-Commit-Position: refs/heads/master@{#533869}
diff --git a/service-workers/service-worker/fetch-canvas-tainting-video-with-range-request.https.html b/service-workers/service-worker/fetch-canvas-tainting-video-with-range-request.https.html
new file mode 100644
index 0000000..f1ff7ae
--- /dev/null
+++ b/service-workers/service-worker/fetch-canvas-tainting-video-with-range-request.https.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Canvas tainting due to video whose responses are fetched via a service worker including range requests</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-helpers.sub.js?pipe=sub"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<body>
+<script>
+// These tests try to test canvas tainting due to a <video> element. The video
+// src URL is same-origin as the page, but the response is fetched via a service
+// worker that does tricky things like returning opaque responses from another
+// origin. Furthermore, this tests range requests so there are multiple
+// responses.
+//
+// We test range requests by having the server return 206 Partial Content to the
+// first request (which doesn't necessarily have a "Range" header or one with a
+// byte range). Then the <video> element automatically makes ranged requests
+// (the "Range" HTTP request header specifies a byte range). The server responds
+// to these with 206 Partial Content for the given range.
+function range_request_test(script, expected, description) {
+ promise_test(t => {
+ let frame;
+ let registration;
+ add_result_callback(() => {
+ if (frame) frame.remove();
+ if (registration) registration.unregister();
+ });
+
+ const scope = 'resources/fetch-canvas-tainting-iframe.html';
+ return service_worker_unregister_and_register(t, script, scope)
+ .then(r => {
+ registration = r;
+ return wait_for_state(t, registration.installing, 'activated');
+ })
+ .then(() => {
+ return with_iframe(scope);
+ })
+ .then(f => {
+ frame = f;
+ // Add "?VIDEO&PartialContent" to get a video resource from the
+ // server using range requests.
+ const video_url = 'fetch-access-control.py?VIDEO&PartialContent';
+ return frame.contentWindow.create_test_case_promise(video_url);
+ })
+ .then(result => {
+ assert_equals(result, expected);
+ });
+ }, description);
+}
+
+// We want to consider a number of scenarios:
+// (1) Range responses come from a single origin, the same-origin as the page.
+// The canvas should not be tainted.
+range_request_test(
+ 'resources/fetch-event-network-fallback-worker.js',
+ 'NOT_TAINTED',
+ 'range responses from single origin (same-origin)');
+
+// (2) Range responses come from a single origin, cross-origin from the page
+// (and without CORS sharing). This is not possible to test, since service
+// worker can't make a request with a "Range" HTTP header in no-cors mode.
+
+// (3) Range responses come from multiple origins. The first response comes from
+// cross-origin (and without CORS sharing, so is opaque). Subsequent
+// responses come from same-origin. The canvas should be tainted (but in
+// Chrome this is a LOAD_ERROR since it disallows range responses from
+// multiple origins, period).
+range_request_test(
+ 'resources/range-request-to-different-origins-worker.js',
+ 'TAINTED',
+ 'range responses from multiple origins (cross-origin first)');
+
+// (4) Range responses come from multiple origins. The first response comes from
+// same-origin. Subsequent responses come from cross-origin (and without
+// CORS sharing). Like (2) this is not possible since the service worker
+// cannot make range requests cross-origin.
+
+// (5) Range responses come from a single origin, with a mix of opaque and
+// non-opaque responses. The first request uses 'no-cors' mode to
+// receive an opaque response, and subsequent range requests use 'cors'
+// to receive non-opaque responses. The canvas should be tainted.
+range_request_test(
+ 'resources/range-request-with-different-cors-modes-worker.js',
+ 'TAINTED',
+ 'range responses from single origin with both opaque and non-opaque responses');
+
+// (6) Range responses come from a single origin, with a mix of opaque and
+// non-opaque responses. The first request uses 'cors' mode to
+// receive an non-opaque response, and subsequent range requests use
+// 'no-cors' to receive non-opaque responses. Like (2) this is not possible.
+</script>
+</body>
diff --git a/service-workers/service-worker/resources/fetch-access-control.py b/service-workers/service-worker/resources/fetch-access-control.py
index c82ffbe..61b89cb 100644
--- a/service-workers/service-worker/resources/fetch-access-control.py
+++ b/service-workers/service-worker/resources/fetch-access-control.py
@@ -36,6 +36,39 @@
if "VIDEO" in request.GET:
headers.append(("Content-Type", "video/webm"))
body = open(os.path.join(request.doc_root, "media", "movie_5.ogv"), "rb").read()
+ length = len(body)
+ # If "PartialContent" is specified, the requestor wants to test range
+ # requests. For the initial request, respond with "206 Partial Content"
+ # and don't send the entire content. Then expect subsequent requests to
+ # have a "Range" header with a byte range. Respond with that range.
+ if "PartialContent" in request.GET:
+ if length < 1:
+ return 500, headers, "file is too small for range requests"
+ start = 0
+ end = length - 1
+ if "Range" in request.headers:
+ range_header = request.headers["Range"]
+ prefix = "bytes="
+ split_header = range_header[len(prefix):].split("-")
+ # The first request might be "bytes=0-". We want to force a range
+ # request, so just return the first byte.
+ if split_header[0] == "0" and split_header[1] == "":
+ end = start
+ # Otherwise, it is a range request. Respect the values sent.
+ if split_header[0] != "":
+ start = int(split_header[0])
+ if split_header[1] != "":
+ end = int(split_header[1])
+ else:
+ # The request doesn't have a range. Force a range request by
+ # returning the first byte.
+ end = start
+
+ headers.append(("Accept-Ranges", "bytes"))
+ headers.append(("Content-Length", str(end -start + 1)))
+ headers.append(("Content-Range", "bytes %d-%d/%d" % (start, end, length)))
+ chunk = body[start:(end + 1)]
+ return 206, headers, chunk
return headers, body
username = request.auth.username if request.auth.username else "undefined"
diff --git a/service-workers/service-worker/resources/fetch-event-network-fallback-worker.js b/service-workers/service-worker/resources/fetch-event-network-fallback-worker.js
new file mode 100644
index 0000000..daa200c
--- /dev/null
+++ b/service-workers/service-worker/resources/fetch-event-network-fallback-worker.js
@@ -0,0 +1,3 @@
+self.addEventListener('fetch', () => {
+ // Do nothing.
+ });
diff --git a/service-workers/service-worker/resources/range-request-to-different-origins-worker.js b/service-workers/service-worker/resources/range-request-to-different-origins-worker.js
new file mode 100644
index 0000000..cab6058
--- /dev/null
+++ b/service-workers/service-worker/resources/range-request-to-different-origins-worker.js
@@ -0,0 +1,40 @@
+// This worker is meant to test range requests where the responses come from
+// multiple origins. It forwards the first request to a cross-origin URL
+// (generating an opaque response). The server is expected to return a 206
+// Partial Content response. Then the worker lets subsequent range requests
+// fall back to network (generating same-origin responses). The intent is to try
+// to trick the browser into treating the resource as same-origin.
+//
+// It would also be interesting to do the reverse test where the first request
+// goes to the same-origin URL, and subsequent range requests go cross-origin in
+// 'no-cors' mode to receive opaque responses. But the service worker cannot do
+// this, because in 'no-cors' mode the 'range' HTTP header is disallowed.
+
+importScripts('/common/get-host-info.sub.js')
+
+let initial = true;
+function is_initial_request() {
+ const old = initial;
+ initial = false;
+ return old;
+}
+
+self.addEventListener('fetch', e => {
+ const url = new URL(e.request.url);
+ if (url.search.indexOf('VIDEO') == -1) {
+ // Fall back for non-video.
+ return;
+ }
+
+ // Make the first request go cross-origin.
+ if (is_initial_request()) {
+ const cross_origin_url = get_host_info().HTTPS_REMOTE_ORIGIN +
+ url.pathname + url.search;
+ const cross_origin_request = new Request(cross_origin_url,
+ {mode: 'no-cors', headers: e.request.headers});
+ e.respondWith(fetch(cross_origin_request));
+ return;
+ }
+
+ // Fall back to same origin for subsequent range requests.
+ });
diff --git a/service-workers/service-worker/resources/range-request-with-different-cors-modes-worker.js b/service-workers/service-worker/resources/range-request-with-different-cors-modes-worker.js
new file mode 100644
index 0000000..7580b0b
--- /dev/null
+++ b/service-workers/service-worker/resources/range-request-with-different-cors-modes-worker.js
@@ -0,0 +1,60 @@
+// This worker is meant to test range requests where the responses are a mix of
+// opaque ones and non-opaque ones. It forwards the first request to a
+// cross-origin URL (generating an opaque response). The server is expected to
+// return a 206 Partial Content response. Then the worker forwards subsequent
+// range requests to that URL, with CORS sharing generating a non-opaque
+// responses. The intent is to try to trick the browser into treating the
+// resource as non-opaque.
+//
+// It would also be interesting to do the reverse test where the first request
+// uses 'cors', and subsequent range requests use 'no-cors' mode. But the
+// service worker cannot do this, because in 'no-cors' mode the 'range' HTTP
+// header is disallowed.
+
+importScripts('/common/get-host-info.sub.js')
+
+let initial = true;
+function is_initial_request() {
+ const old = initial;
+ initial = false;
+ return old;
+}
+
+self.addEventListener('fetch', e => {
+ const url = new URL(e.request.url);
+ if (url.search.indexOf('VIDEO') == -1) {
+ // Fall back for non-video.
+ return;
+ }
+
+ let cross_origin_url = get_host_info().HTTPS_REMOTE_ORIGIN +
+ url.pathname + url.search;
+
+ // The first request is no-cors.
+ if (is_initial_request()) {
+ const init = { mode: 'no-cors', headers: e.request.headers };
+ const cross_origin_request = new Request(cross_origin_url, init);
+ e.respondWith(fetch(cross_origin_request));
+ return;
+ }
+
+ // Subsequent range requests are cors.
+
+ // Copy headers needed for range requests.
+ let my_headers = new Headers;
+ if (e.request.headers.get('accept'))
+ my_headers.append('accept', e.request.headers.get('accept'));
+ if (e.request.headers.get('range'))
+ my_headers.append('range', e.request.headers.get('range'));
+
+ // Add &ACAOrigin to allow CORS.
+ cross_origin_url += '&ACAOrigin=' + get_host_info().HTTPS_ORIGIN;
+ // Add &ACAHeaders to allow range requests.
+ cross_origin_url += '&ACAHeaders=accept,range';
+
+ // Make the CORS request.
+ const init = { mode: 'cors', headers: my_headers };
+ const cross_origin_request = new Request(cross_origin_url, init);
+ e.respondWith(fetch(cross_origin_request));
+ });
+