summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/fetch/private-network-access/resources
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/web-platform/tests/fetch/private-network-access/resources
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/executor.html9
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/fetcher.html21
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/fetcher.js20
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/iframed.html7
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/iframer.html9
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/preflight.py165
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/service-worker-bridge.html155
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/service-worker.js18
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/shared-fetcher.js23
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/shared-worker-fetcher.html19
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/socket-opener.html15
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/support.sub.js650
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.html18
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/worker-fetcher.js11
-rw-r--r--testing/web-platform/tests/fetch/private-network-access/resources/xhr-sender.html33
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>