diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/web-platform/tests/fetch/private-network-access/resources | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/fetch/private-network-access/resources')
15 files changed, 1173 insertions, 0 deletions
diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/executor.html b/testing/web-platform/tests/fetch/private-network-access/resources/executor.html new file mode 100644 index 0000000000..d71212951c --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/executor.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Executor</title> +<body></body> +<script src="/common/dispatcher/dispatcher.js"></script> +<script> + const uuid = new URL(window.location).searchParams.get("executor-uuid"); + const executor = new Executor(uuid); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.html b/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.html new file mode 100644 index 0000000000..000a5cc25b --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Fetcher</title> +<script> + window.addEventListener("message", function (event) { + const { url, options } = event.data; + fetch(url, options) + .then(async function(response) { + const body = await response.text(); + const message = { + ok: response.ok, + type: response.type, + body: body, + }; + parent.postMessage(message, "*"); + }) + .catch(error => { + parent.postMessage({ error: error.toString() }, "*"); + }); + }); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.js b/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.js new file mode 100644 index 0000000000..3a1859876d --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/fetcher.js @@ -0,0 +1,20 @@ +async function doFetch(url) { + const response = await fetch(url); + const body = await response.text(); + return { + status: response.status, + body, + }; +} + +async function fetchAndPost(url) { + try { + const message = await doFetch(url); + self.postMessage(message); + } catch(e) { + self.postMessage({ error: e.name }); + } +} + +const url = new URL(self.location.href).searchParams.get("url"); +fetchAndPost(url); diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/iframed.html b/testing/web-platform/tests/fetch/private-network-access/resources/iframed.html new file mode 100644 index 0000000000..c889c2882a --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/iframed.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Iframed</title> +<script> + const uuid = new URL(window.location).searchParams.get("iframe-uuid"); + top.postMessage({ uuid, message: "loaded" }, "*"); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/iframer.html b/testing/web-platform/tests/fetch/private-network-access/resources/iframer.html new file mode 100644 index 0000000000..304cc54ae4 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/iframer.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Iframer</title> +<body></body> +<script> + const child = document.createElement("iframe"); + child.src = new URL(window.location).searchParams.get("url"); + document.body.appendChild(child); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/preflight.py b/testing/web-platform/tests/fetch/private-network-access/resources/preflight.py new file mode 100644 index 0000000000..41daebf08d --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/preflight.py @@ -0,0 +1,165 @@ +# This endpoint responds to both preflight requests and the subsequent requests. +# +# Its behavior can be configured with various search/GET parameters, all of +# which are optional: +# +# - treat-as-public-once: Must be a valid UUID if set. +# If set, then this endpoint expects to receive a non-preflight request first, +# for which it sets the `Content-Security-Policy: treat-as-public-address` +# response header. This allows testing "DNS rebinding", where a URL first +# resolves to the public IP address space, then a non-public IP address space. +# - preflight-uuid: Must be a valid UUID if set, distinct from the value of the +# `treat-as-public-once` parameter if both are set. +# If set, then this endpoint expects to receive a preflight request first +# followed by a regular request, as in the regular CORS protocol. If the +# `treat-as-public-once` header is also set, it takes precedence: this +# endpoint expects to receive a non-preflight request first, then a preflight +# request, then finally a regular request. +# If unset, then this endpoint expects to receive no preflight request, only +# a regular (non-OPTIONS) request. +# - preflight-headers: Valid values are: +# - cors: this endpoint responds with valid CORS headers to preflights. These +# should be sufficient for non-PNA preflight requests to succeed, but not +# for PNA-specific preflight requests. +# - cors+pna: this endpoint responds with valid CORS and PNA headers to +# preflights. These should be sufficient for both non-PNA preflight +# requests and PNA-specific preflight requests to succeed. +# - cors+pna+sw: this endpoint responds with valid CORS and PNA headers and +# "Access-Control-Allow-Headers: Service-Worker" to preflights. These should +# be sufficient for both non-PNA preflight requests and PNA-specific +# preflight requests to succeed. This allows the main request to fetch a +# service worker script. +# - unspecified, or any other value: this endpoint responds with no CORS or +# PNA headers. Preflight requests should fail. +# - final-headers: Valid values are: +# - cors: this endpoint responds with valid CORS headers to CORS-enabled +# non-preflight requests. These should be sufficient for non-preflighted +# CORS-enabled requests to succeed. +# - unspecified: this endpoint responds with no CORS headers to non-preflight +# requests. This should fail CORS-enabled requests, but be sufficient for +# no-CORS requests. +# +# The following parameters only affect non-preflight responses: +# +# - redirect: If set, the response code is set to 301 and the `Location` +# response header is set to this value. +# - mime-type: If set, the `Content-Type` response header is set to this value. +# - file: Specifies a path (relative to this file's directory) to a file. If +# set, the response body is copied from this file. +# - random-js-prefix: If set to any value, the response body is prefixed with +# a Javascript comment line containing a random value. This is useful in +# service worker tests, since service workers are only updated if the new +# script is not byte-for-byte identical with the old script. +# - body: If set and `file` is not, the response body is set to this value. +# + +import os +import random + +from wptserve.utils import isomorphic_encode + +_ACAO = ("Access-Control-Allow-Origin", "*") +_ACAPN = ("Access-Control-Allow-Private-Network", "true") +_ACAH = ("Access-Control-Allow-Headers", "Service-Worker") + +def _get_response_headers(method, mode): + acam = ("Access-Control-Allow-Methods", method) + + if mode == b"cors": + return [acam, _ACAO] + + if mode == b"cors+pna": + return [acam, _ACAO, _ACAPN] + + if mode == b"cors+pna+sw": + return [acam, _ACAO, _ACAPN, _ACAH] + + return [] + +def _get_expect_single_preflight(request): + return request.GET.get(b"expect-single-preflight") + +def _get_preflight_uuid(request): + return request.GET.get(b"preflight-uuid") + +def _should_treat_as_public_once(request): + uuid = request.GET.get(b"treat-as-public-once") + if uuid is None: + # If the search parameter is not given, never treat as public. + return False + + # If the parameter is given, we treat the request as public only if the UUID + # has never been seen and stashed. + result = request.server.stash.take(uuid) is None + request.server.stash.put(uuid, "") + return result + +def _handle_preflight_request(request, response): + if _should_treat_as_public_once(request): + return (400, [], "received preflight for first treat-as-public request") + + uuid = _get_preflight_uuid(request) + if uuid is None: + return (400, [], "missing `preflight-uuid` param from preflight URL") + + value = request.server.stash.take(uuid) + request.server.stash.put(uuid, "preflight") + if _get_expect_single_preflight(request) and value is not None: + return (400, [], "received duplicated preflight") + + method = request.headers.get("Access-Control-Request-Method") + mode = request.GET.get(b"preflight-headers") + headers = _get_response_headers(method, mode) + + return (headers, "preflight") + +def _final_response_body(request): + file_name = request.GET.get(b"file") + if file_name is None: + return request.GET.get(b"body") or "success" + + prefix = b"" + if request.GET.get(b"random-js-prefix"): + value = random.randint(0, 1000000000) + prefix = isomorphic_encode("// Random value: {}\n\n".format(value)) + + path = os.path.join(os.path.dirname(isomorphic_encode(__file__)), file_name) + with open(path, 'rb') as f: + contents = f.read() + + return prefix + contents + +def _handle_final_request(request, response): + if _should_treat_as_public_once(request): + headers = [("Content-Security-Policy", "treat-as-public-address"),] + else: + uuid = _get_preflight_uuid(request) + if uuid is not None: + if request.server.stash.take(uuid) is None: + return (405, [], "no preflight received for {}".format(uuid)) + request.server.stash.put(uuid, "final") + + mode = request.GET.get(b"final-headers") + headers = _get_response_headers(request.method, mode) + + redirect = request.GET.get(b"redirect") + if redirect is not None: + headers.append(("Location", redirect)) + return (301, headers, b"") + + mime_type = request.GET.get(b"mime-type") + if mime_type is not None: + headers.append(("Content-Type", mime_type),) + + body = _final_response_body(request) + return (headers, body) + +def main(request, response): + try: + if request.method == "OPTIONS": + return _handle_preflight_request(request, response) + else: + return _handle_final_request(request, response) + except BaseException as e: + # Surface exceptions to the client, where they show up as assertion errors. + return (500, [("X-exception", str(e))], "exception: {}".format(e)) diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/service-worker-bridge.html b/testing/web-platform/tests/fetch/private-network-access/resources/service-worker-bridge.html new file mode 100644 index 0000000000..816de535fe --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/service-worker-bridge.html @@ -0,0 +1,155 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>ServiceWorker Bridge</title> +<script src="/resources/testdriver.js"></script> +<script src="/resources/testdriver-vendor.js"></script> +<script> + // This bridge document exists to perform service worker commands on behalf + // of a test page. It lives within the same scope (including origin) as the + // service worker script, allowing it to be controlled by the service worker. + + async function register({ url, options }) { + await navigator.serviceWorker.register(url, options); + return { loaded: true }; + } + + async function unregister({ scope }) { + const registration = await navigator.serviceWorker.getRegistration(scope); + if (!registration) { + return { unregistered: false, error: "no registration" }; + } + + const unregistered = await registration.unregister(); + return { unregistered }; + } + + async function update({ scope }) { + const registration = await navigator.serviceWorker.getRegistration(scope); + if (!registration) { + return { updated: false, error: "no registration" }; + } + + const newRegistration = await registration.update(); + return { updated: true }; + } + + // Total number of `controllerchange` events since document creation. + let totalNumControllerChanges = 0; + navigator.serviceWorker.addEventListener("controllerchange", () => { + totalNumControllerChanges++; + }); + + // Using `navigator.serviceWorker.ready` does not allow noticing new + // controllers after an update, so we count `controllerchange` events instead. + // This has the added benefit of ensuring that subsequent fetches are handled + // by the service worker, whereas `ready` does not guarantee that. + async function wait({ numControllerChanges }) { + if (totalNumControllerChanges >= numControllerChanges) { + return { + controlled: !!navigator.serviceWorker.controller, + numControllerChanges: totalNumControllerChanges, + }; + } + + let remaining = numControllerChanges - totalNumControllerChanges; + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener("controllerchange", () => { + remaining--; + if (remaining == 0) { + resolve(); + } + }); + }); + + return { + controlled: !!navigator.serviceWorker.controller, + numControllerChanges, + }; + } + + async function doFetch({ url, options }) { + const response = await fetch(url, options); + const body = await response.text(); + return { + ok: response.ok, + body, + }; + } + + async function setPermission({ name, state }) { + await test_driver.set_permission({ name }, state); + + // Double-check, just to be sure. + // See the comment in `../service-worker-background-fetch.js`. + const permissionStatus = await navigator.permissions.query({ name }); + return { state: permissionStatus.state }; + } + + async function backgroundFetch({ scope, url }) { + const registration = await navigator.serviceWorker.getRegistration(scope); + if (!registration) { + return { error: "no registration" }; + } + + const fetchRegistration = + await registration.backgroundFetch.fetch("test", url); + const resultReady = new Promise((resolve) => { + fetchRegistration.addEventListener("progress", () => { + if (fetchRegistration.result) { + resolve(); + } + }); + }); + + let ok; + let body; + const record = await fetchRegistration.match(url); + if (record) { + const response = await record.responseReady; + body = await response.text(); + ok = response.ok; + } + + // Wait for the result after getting the response. If the steps are + // inverted, then sometimes the response is not found due to an + // `UnknownError`. + await resultReady; + + return { + result: fetchRegistration.result, + failureReason: fetchRegistration.failureReason, + ok, + body, + }; + } + + function getAction(action) { + switch (action) { + case "register": + return register; + case "unregister": + return unregister; + case "wait": + return wait; + case "update": + return update; + case "fetch": + return doFetch; + case "set-permission": + return setPermission; + case "background-fetch": + return backgroundFetch; + } + } + + window.addEventListener("message", async (evt) => { + let message; + try { + const action = getAction(evt.data.action); + message = await action(evt.data); + } catch(e) { + message = { error: e.name }; + } + parent.postMessage(message, "*"); + }); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/service-worker.js b/testing/web-platform/tests/fetch/private-network-access/resources/service-worker.js new file mode 100644 index 0000000000..bca71ad910 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/service-worker.js @@ -0,0 +1,18 @@ +self.addEventListener("install", () => { + // Skip waiting before replacing the previously-active service worker, if any. + // This allows the bridge script to notice the controller change and query + // the install time via fetch. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + // Claim all clients so that the bridge script notices the activation. + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url).searchParams.get("proxied-url"); + if (url) { + event.respondWith(fetch(url)); + } +}); diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/shared-fetcher.js b/testing/web-platform/tests/fetch/private-network-access/resources/shared-fetcher.js new file mode 100644 index 0000000000..30bde1e054 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/shared-fetcher.js @@ -0,0 +1,23 @@ +async function doFetch(url) { + const response = await fetch(url); + const body = await response.text(); + return { + status: response.status, + body, + }; +} + +async function fetchAndPost(url, port) { + try { + const message = await doFetch(url); + port.postMessage(message); + } catch(e) { + port.postMessage({ error: e.name }); + } +} + +const url = new URL(self.location.href).searchParams.get("url"); + +self.addEventListener("connect", async (evt) => { + await fetchAndPost(url, evt.ports[0]); +}); diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/shared-worker-fetcher.html b/testing/web-platform/tests/fetch/private-network-access/resources/shared-worker-fetcher.html new file mode 100644 index 0000000000..4af4b1f239 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/shared-worker-fetcher.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>SharedWorker Fetcher</title> +<script> + window.addEventListener("message", function (evt) { + let { url } = evt.data; + + const worker = new SharedWorker(url); + + worker.onerror = (evt) => { + parent.postMessage({ error: evt.message || "unknown error" }, "*"); + }; + + worker.port.addEventListener("message", (evt) => { + parent.postMessage(evt.data, "*"); + }); + worker.port.start(); + }); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/socket-opener.html b/testing/web-platform/tests/fetch/private-network-access/resources/socket-opener.html new file mode 100644 index 0000000000..48d27216be --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/socket-opener.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>WebSocket Opener</title> +<script> + window.addEventListener("message", function (event) { + const socket = new WebSocket(event.data); + + socket.onopen = () => { + parent.postMessage("open", "*"); + }; + socket.onclose = (evt) => { + parent.postMessage(`close: code ${evt.code}`, "*"); + }; + }); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/support.sub.js b/testing/web-platform/tests/fetch/private-network-access/resources/support.sub.js new file mode 100644 index 0000000000..adbafac304 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/support.sub.js @@ -0,0 +1,650 @@ +// Creates a new iframe in `doc`, calls `func` on it and appends it as a child +// of `doc`. +// Returns a promise that resolves to the iframe once loaded (successfully or +// not). +// The iframe is removed from `doc` once test `t` is done running. +// +// NOTE: There exists no interoperable way to check whether an iframe failed to +// load, so this should only be used when the iframe is expected to load. It +// also means we cannot wire the iframe's `error` event to a promise +// rejection. See: https://github.com/whatwg/html/issues/125 +function appendIframeWith(t, doc, func) { + return new Promise(resolve => { + const child = doc.createElement("iframe"); + t.add_cleanup(() => child.remove()); + + child.addEventListener("load", () => resolve(child), { once: true }); + func(child); + doc.body.appendChild(child); + }); +} + +// Appends a child iframe to `doc` sourced from `src`. +// +// See `appendIframeWith()` for more details. +function appendIframe(t, doc, src) { + return appendIframeWith(t, doc, child => { child.src = src; }); +} + +// Registers an event listener that will resolve this promise when this +// window receives a message posted to it. +// +// `options` has the following shape: +// +// { +// source: If specified, this function waits for the first message from the +// given source only, ignoring other messages. +// +// filter: If specified, this function calls `filter` on each incoming +// message, and resolves iff it returns true. +// } +// +function futureMessage(options) { + return new Promise(resolve => { + window.addEventListener("message", (e) => { + if (options?.source && options.source !== e.source) { + return; + } + + if (options?.filter && !options.filter(e.data)) { + return; + } + + resolve(e.data); + }); + }); +}; + +// Like `promise_test()`, but executes tests in parallel like `async_test()`. +// +// Cribbed from COEP tests. +function promise_test_parallel(promise, description) { + async_test(test => { + promise(test) + .then(() => test.done()) + .catch(test.step_func(error => { throw error; })); + }, description); +}; + +async function postMessageAndAwaitReply(target, message) { + const reply = futureMessage({ source: target }); + target.postMessage(message, "*"); + return await reply; +} + +// Maps protocol (without the trailing colon) and address space to port. +const SERVER_PORTS = { + "http": { + "local": {{ports[http][0]}}, + "private": {{ports[http-private][0]}}, + "public": {{ports[http-public][0]}}, + }, + "https": { + "local": {{ports[https][0]}}, + "private": {{ports[https-private][0]}}, + "public": {{ports[https-public][0]}}, + }, + "ws": { + "local": {{ports[ws][0]}}, + }, + "wss": { + "local": {{ports[wss][0]}}, + }, +}; + +// A `Server` is a web server accessible by tests. It has the following shape: +// +// { +// addressSpace: the IP address space of the server ("local", "private" or +// "public"), +// name: a human-readable name for the server, +// port: the port on which the server listens for connections, +// protocol: the protocol (including trailing colon) spoken by the server, +// } +// +// Constants below define the available servers, which can also be accessed +// programmatically with `get()`. +class Server { + // Maps the given `protocol` (without a trailing colon) and `addressSpace` to + // a server. Returns null if no such server exists. + static get(protocol, addressSpace) { + const ports = SERVER_PORTS[protocol]; + if (ports === undefined) { + return null; + } + + const port = ports[addressSpace]; + if (port === undefined) { + return null; + } + + return { + addressSpace, + name: `${protocol}-${addressSpace}`, + port, + protocol: protocol + ':', + }; + } + + static HTTP_LOCAL = Server.get("http", "local"); + static HTTP_PRIVATE = Server.get("http", "private"); + static HTTP_PUBLIC = Server.get("http", "public"); + static HTTPS_LOCAL = Server.get("https", "local"); + static HTTPS_PRIVATE = Server.get("https", "private"); + static HTTPS_PUBLIC = Server.get("https", "public"); + static WS_LOCAL = Server.get("ws", "local"); + static WSS_LOCAL = Server.get("wss", "local"); +}; + +// Resolves a URL relative to the current location, returning an absolute URL. +// +// `url` specifies the relative URL, e.g. "foo.html" or "http://foo.example". +// `options`, if defined, should have the following shape: +// +// { +// // Optional. Overrides the protocol of the returned URL. +// protocol, +// +// // Optional. Overrides the port of the returned URL. +// port, +// +// // Extra headers. +// headers, +// +// // Extra search params. +// searchParams, +// } +// +function resolveUrl(url, options) { + const result = new URL(url, window.location); + if (options === undefined) { + return result; + } + + const { port, protocol, headers, searchParams } = options; + if (port !== undefined) { + result.port = port; + } + if (protocol !== undefined) { + result.protocol = protocol; + } + if (headers !== undefined) { + const pipes = []; + for (key in headers) { + pipes.push(`header(${key},${headers[key]})`); + } + result.searchParams.append("pipe", pipes.join("|")); + } + if (searchParams !== undefined) { + for (key in searchParams) { + result.searchParams.append(key, searchParams[key]); + } + } + + return result; +} + +// Computes options to pass to `resolveUrl()` for a source document's URL. +// +// `server` identifies the server from which to load the document. +// `treatAsPublic`, if set to true, specifies that the source document should +// be artificially placed in the `public` address space using CSP. +function sourceResolveOptions({ server, treatAsPublic }) { + const options = {...server}; + if (treatAsPublic) { + options.headers = { "Content-Security-Policy": "treat-as-public-address" }; + } + return options; +} + +// Computes the URL of a preflight handler configured with the given options. +// +// `server` identifies the server from which to load the resource. +// `behavior` specifies the behavior of the target server. It may contain: +// - `preflight`: The result of calling one of `PreflightBehavior`'s methods. +// - `response`: The result of calling one of `ResponseBehavior`'s methods. +// - `redirect`: A URL to which the target should redirect GET requests. +function preflightUrl({ server, behavior }) { + const options = {...server}; + if (behavior) { + const { preflight, response, redirect } = behavior; + options.searchParams = { + ...preflight, + ...response, + }; + if (redirect !== undefined) { + options.searchParams.redirect = redirect; + } + } + + return resolveUrl("resources/preflight.py", options); +} + +// Methods generate behavior specifications for how `resources/preflight.py` +// should behave upon receiving a preflight request. +const PreflightBehavior = { + // The preflight response should fail with a non-2xx code. + failure: () => ({}), + + // The preflight response should be missing CORS headers. + // `uuid` should be a UUID that uniquely identifies the preflight request. + noCorsHeader: (uuid) => ({ + "preflight-uuid": uuid, + }), + + // The preflight response should be missing PNA headers. + // `uuid` should be a UUID that uniquely identifies the preflight request. + noPnaHeader: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors", + }), + + // The preflight response should succeed. + // `uuid` should be a UUID that uniquely identifies the preflight request. + success: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna", + }), + + // The preflight response should succeed and allow service-worker header. + // `uuid` should be a UUID that uniquely identifies the preflight request. + serviceWorkerSuccess: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna+sw", + }), + + // The preflight response should succeed only if it is the first preflight. + // `uuid` should be a UUID that uniquely identifies the preflight request. + singlePreflight: (uuid) => ({ + "preflight-uuid": uuid, + "preflight-headers": "cors+pna", + "expect-single-preflight": true, + }), +}; + +// Methods generate behavior specifications for how `resources/preflight.py` +// should behave upon receiving a regular (non-preflight) request. +const ResponseBehavior = { + // The response should succeed without CORS headers. + default: () => ({}), + + // The response should succeed with CORS headers. + allowCrossOrigin: () => ({ "final-headers": "cors" }), +}; + +const FetchTestResult = { + SUCCESS: { + ok: true, + body: "success", + }, + OPAQUE: { + ok: false, + type: "opaque", + body: "", + }, + FAILURE: { + error: "TypeError: Failed to fetch", + }, +}; + +// Runs a fetch test. Tries to fetch a given subresource from a given document. +// +// Main argument shape: +// +// { +// // Optional. Passed to `sourceResolveOptions()`. +// source, +// +// // Optional. Passed to `preflightUrl()`. +// target, +// +// // Optional. Passed to `fetch()`. +// fetchOptions, +// +// // Required. One of the values in `FetchTestResult`. +// expected, +// } +// +async function fetchTest(t, { source, target, fetchOptions, expected }) { + const sourceUrl = + resolveUrl("resources/fetcher.html", sourceResolveOptions(source)); + + const targetUrl = preflightUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage({ source: iframe.contentWindow }); + + const message = { + url: targetUrl.href, + options: fetchOptions, + }; + iframe.contentWindow.postMessage(message, "*"); + + const { error, ok, type, body } = await reply; + + assert_equals(error, expected.error, "error"); + + assert_equals(ok, expected.ok, "response ok"); + assert_equals(body, expected.body, "response body"); + + if (expected.type !== undefined) { + assert_equals(type, expected.type, "response type"); + } +} + +const XhrTestResult = { + SUCCESS: { + loaded: true, + status: 200, + body: "success", + }, + FAILURE: { + loaded: false, + status: 0, + }, +}; + +// Runs an XHR test. Tries to fetch a given subresource from a given document. +// +// Main argument shape: +// +// { +// // Optional. Passed to `sourceResolveOptions()`. +// source, +// +// // Optional. Passed to `preflightUrl()`. +// target, +// +// // Optional. Method to use when sending the request. Defaults to "GET". +// method, +// +// // Required. One of the values in `XhrTestResult`. +// expected, +// } +// +async function xhrTest(t, { source, target, method, expected }) { + const sourceUrl = + resolveUrl("resources/xhr-sender.html", sourceResolveOptions(source)); + + const targetUrl = preflightUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + const message = { + url: targetUrl.href, + method: method, + }; + iframe.contentWindow.postMessage(message, "*"); + + const { loaded, status, body } = await reply; + + assert_equals(loaded, expected.loaded, "response loaded"); + assert_equals(status, expected.status, "response status"); + assert_equals(body, expected.body, "response body"); +} + +const IframeTestResult = { + SUCCESS: "loaded", + FAILURE: "timeout", +}; + +async function iframeTest(t, { source, target, expected }) { + // Allows running tests in parallel. + const uuid = token(); + + const targetUrl = preflightUrl(target); + targetUrl.searchParams.set("file", "iframed.html"); + targetUrl.searchParams.set("iframe-uuid", uuid); + + const sourceUrl = + resolveUrl("resources/iframer.html", sourceResolveOptions(source)); + sourceUrl.searchParams.set("url", targetUrl); + + const messagePromise = futureMessage({ + filter: (data) => data.uuid === uuid, + }); + const iframe = await appendIframe(t, document, sourceUrl); + + // The grandchild frame posts a message iff it loads successfully. + // There exists no interoperable way to check whether an iframe failed to + // load, so we use a timeout. + // See: https://github.com/whatwg/html/issues/125 + const result = await Promise.race([ + messagePromise.then((data) => data.message), + new Promise((resolve) => { + t.step_timeout(() => resolve("timeout"), 500 /* ms */); + }), + ]); + + assert_equals(result, expected); +} + +const iframeGrandparentTest = ({ + name, + grandparentServer, + child, + grandchild, + expected, +}) => promise_test_parallel(async (t) => { + // Allows running tests in parallel. + const grandparentUuid = token(); + const childUuid = token(); + const grandchildUuid = token(); + + const grandparentUrl = + resolveUrl("resources/executor.html", grandparentServer); + grandparentUrl.searchParams.set("executor-uuid", grandparentUuid); + + const childUrl = preflightUrl(child); + childUrl.searchParams.set("file", "executor.html"); + childUrl.searchParams.set("executor-uuid", childUuid); + + const grandchildUrl = preflightUrl(grandchild); + grandchildUrl.searchParams.set("file", "iframed.html"); + grandchildUrl.searchParams.set("iframe-uuid", grandchildUuid); + + const iframe = await appendIframe(t, document, grandparentUrl); + + const addChild = (url) => new Promise((resolve) => { + const child = document.createElement("iframe"); + child.src = url; + child.addEventListener("load", () => resolve(), { once: true }); + document.body.appendChild(child); + }); + + const grandparentCtx = new RemoteContext(grandparentUuid); + await grandparentCtx.execute_script(addChild, [childUrl]); + + // Add a blank grandchild frame inside the child. + // Apply a timeout to this step so that failures at this step do not block the + // execution of other tests. + const childCtx = new RemoteContext(childUuid); + await Promise.race([ + childCtx.execute_script(addChild, ["about:blank"]), + new Promise((resolve, reject) => t.step_timeout( + () => reject("timeout adding grandchild"), + 2000 /* ms */ + )), + ]); + + const messagePromise = futureMessage({ + filter: (data) => data.uuid === grandchildUuid, + }); + await grandparentCtx.execute_script((url) => { + const child = window.frames[0]; + const grandchild = child.frames[0]; + grandchild.location = url; + }, [grandchildUrl]); + + // The great-grandchild frame posts a message iff it loads successfully. + // There exists no interoperable way to check whether an iframe failed to + // load, so we use a timeout. + // See: https://github.com/whatwg/html/issues/125 + const result = await Promise.race([ + messagePromise.then((data) => data.message), + new Promise((resolve) => { + t.step_timeout(() => resolve("timeout"), 2000 /* ms */); + }), + ]); + + assert_equals(result, expected); +}, name); + +const WebsocketTestResult = { + SUCCESS: "open", + + // The code is a best guess. It is not yet entirely specified, so it may need + // to be changed in the future based on implementation experience. + FAILURE: "close: code 1006", +}; + +// Runs a websocket test. Attempts to open a websocket from `source` (in an +// iframe) to `target`, then checks that the result is as `expected`. +// +// Argument shape: +// +// { +// // Required. Passed to `sourceResolveOptions()`. +// source, +// +// // Required. +// target: { +// // Required. Target server. +// server, +// } +// +// // Required. Should be one of the values in `WebsocketTestResult`. +// expected, +// } +// +async function websocketTest(t, { source, target, expected }) { + const sourceUrl = + resolveUrl("resources/socket-opener.html", sourceResolveOptions(source)); + + const targetUrl = resolveUrl("/echo", target.server); + + const iframe = await appendIframe(t, document, sourceUrl); + + const reply = futureMessage(); + iframe.contentWindow.postMessage(targetUrl.href, "*"); + + assert_equals(await reply, expected); +} + +const WorkerScriptTestResult = { + SUCCESS: { loaded: true }, + FAILURE: { error: "unknown error" }, +}; + +function workerScriptUrl(target) { + const url = preflightUrl(target); + + url.searchParams.append("body", "postMessage({ loaded: true })") + url.searchParams.append("mime-type", "application/javascript") + + return url; +} + +async function workerScriptTest(t, { source, target, expected }) { + const sourceUrl = + resolveUrl("resources/worker-fetcher.html", sourceResolveOptions(source)); + + const targetUrl = workerScriptUrl(target); + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +async function nestedWorkerScriptTest(t, { source, target, expected }) { + const targetUrl = workerScriptUrl(target); + + const sourceUrl = resolveUrl( + "resources/worker-fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl); + + // Iframe must be same-origin with the parent worker. + const iframeUrl = new URL("worker-fetcher.html", sourceUrl); + + const iframe = await appendIframe(t, document, iframeUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +async function sharedWorkerScriptTest(t, { source, target, expected }) { + const sourceUrl = resolveUrl("resources/shared-worker-fetcher.html", + sourceResolveOptions(source)); + const targetUrl = preflightUrl(target); + targetUrl.searchParams.append( + "body", "onconnect = (e) => e.ports[0].postMessage({ loaded: true })") + targetUrl.searchParams.append("mime-type", "application/javascript") + + const iframe = await appendIframe(t, document, sourceUrl); + const reply = futureMessage(); + + iframe.contentWindow.postMessage({ url: targetUrl.href }, "*"); + + const { error, loaded } = await reply; + + assert_equals(error, expected.error, "worker error"); + assert_equals(loaded, expected.loaded, "response loaded"); +} + +// Results that may be expected in tests. +const WorkerFetchTestResult = { + SUCCESS: { status: 200, body: "success" }, + FAILURE: { error: "TypeError" }, +}; + +async function workerFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const sourceUrl = + resolveUrl("resources/fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl.href); + + const fetcherUrl = new URL("worker-fetcher.html", sourceUrl); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, status, message } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(message, expected.message, "response body"); +} + +async function sharedWorkerFetchTest(t, { source, target, expected }) { + const targetUrl = preflightUrl(target); + + const sourceUrl = + resolveUrl("resources/shared-fetcher.js", sourceResolveOptions(source)); + sourceUrl.searchParams.append("url", targetUrl.href); + + const fetcherUrl = new URL("shared-worker-fetcher.html", sourceUrl); + + const reply = futureMessage(); + const iframe = await appendIframe(t, document, fetcherUrl); + + iframe.contentWindow.postMessage({ url: sourceUrl.href }, "*"); + + const { error, status, message } = await reply; + assert_equals(error, expected.error, "fetch error"); + assert_equals(status, expected.status, "response status"); + assert_equals(message, expected.message, "response body"); +} diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.html b/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.html new file mode 100644 index 0000000000..bd155a532b --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Worker Fetcher</title> +<script> + window.addEventListener("message", function (evt) { + let { url } = evt.data; + + const worker = new Worker(url); + + worker.addEventListener("message", (evt) => { + parent.postMessage(evt.data, "*"); + }); + + worker.addEventListener("error", (evt) => { + parent.postMessage({ error: evt.message || "unknown error" }, "*"); + }); + }); +</script> diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.js b/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.js new file mode 100644 index 0000000000..aab49afe6f --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.js @@ -0,0 +1,11 @@ +const url = new URL(self.location).searchParams.get("url"); +const worker = new Worker(url); + +// Relay messages from the worker to the parent frame. +worker.addEventListener("message", (evt) => { + self.postMessage(evt.data); +}); + +worker.addEventListener("error", (evt) => { + self.postMessage({ error: evt.message || "unknown error" }); +}); diff --git a/testing/web-platform/tests/fetch/private-network-access/resources/xhr-sender.html b/testing/web-platform/tests/fetch/private-network-access/resources/xhr-sender.html new file mode 100644 index 0000000000..b131fa41f9 --- /dev/null +++ b/testing/web-platform/tests/fetch/private-network-access/resources/xhr-sender.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>XHR Sender</title> +<script> + window.addEventListener("message", function (event) { + let { url, method } = event.data; + if (!method) { + method = "GET"; + } + + const xhr = new XMLHttpRequest; + + xhr.addEventListener("load", (evt) => { + const message = { + loaded: true, + status: xhr.status, + body: xhr.responseText, + }; + parent.postMessage(message, "*"); + }); + + xhr.addEventListener("error", (evt) => { + const message = { + loaded: false, + status: xhr.status, + }; + parent.postMessage(message, "*"); + }); + + xhr.open(method, url); + xhr.send(); + }); +</script> |