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));
+  });
+