The room client took its relay, room service, fixture, WebTransport URL, certificate hashes and issuer directory from its query string as given, and followed a relay's GOAWAY to any URI. A crafted link and a press of Join would send a joiner's camera and microphone to another origin. - web/room/endpoint.js: an endpoint is taken only on the page's own origin (`https://<host>`, or `wss://<host>` for a relay), never with user information; a page served from the loopback interface (a fixture page, the e2e harness) takes loopback addresses alone. - app.js: `service`, `ws`, `wt`, `fixture` and `issuer_directory` are held to it; `hashes` only on a loopback page. A refused endpoint is recorded (`snapshot().refused`) and shown, Join stays off, and the page connects to nothing: `enter()` checks the relay before it asks for the camera, a grant or a session. A GOAWAY naming another origin is refused: the page closes its session and says so. - web/room/endpoint.test.mjs (run by e2e/run.sh): own origin, another origin, scheme-relative, the page's host as user information, javascript:, a plain scheme on a secure page, an HTTP page, and loopback for the harness. - e2e scenario `origin`: a foreign `?ws=` is refused at Join and nothing connects (no WebSocket, request, grant or camera); a foreign `?service=`, scheme-relative, behind user information or as javascript:, is refused with Join off; a GOAWAY to another origin is refused and the session ends. The `service` scenario checks that every request and WebSocket of every page stays on the page's origin, as the production `connect-src` requires. Co-Authored-By: Claude Opus 5.5 <[email protected]>
71 lines
4.2 KiB
JavaScript
71 lines
4.2 KiB
JavaScript
// The room page's check of the endpoints it takes from its query string and from a relay's
|
|
// GOAWAY (`node --test web/room/endpoint.test.mjs`): its own origin only, and on a loopback
|
|
// page (the end-to-end harness) loopback alone.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { checkEndpoint, kindOf } from "./endpoint.js";
|
|
|
|
const PAGE = "https://wai.transaction.science/room/?service=x";
|
|
const taken = (v, kind, page = PAGE) => checkEndpoint(v, kind, page);
|
|
|
|
test("the page's own origin is taken, a relative path resolved against it", () => {
|
|
assert.deepEqual(taken("/room-api", "http"), { ok: true, url: "https://wai.transaction.science/room-api" });
|
|
assert.deepEqual(taken("https://wai.transaction.science/room-api", "http"), { ok: true, url: "https://wai.transaction.science/room-api" });
|
|
assert.deepEqual(taken("wss://wai.transaction.science/moq", "ws"), { ok: true, url: "wss://wai.transaction.science/moq" });
|
|
});
|
|
|
|
test("another origin is refused, whatever its form", () => {
|
|
for (const [v, kind] of [
|
|
["https://evil.example/room-api", "http"],
|
|
["wss://evil.example/moq", "ws"],
|
|
["https://wai.transaction.science.evil.example/moq", "http"],
|
|
["https://wai.transaction.science:8443/room-api", "http"],
|
|
// A scheme-relative URL takes the page's scheme and another host.
|
|
["//evil.example/room-api", "http"],
|
|
["//evil.example/moq", "ws"],
|
|
// The page's host as user information: the host is the other one.
|
|
["https://[email protected]/room-api", "http"],
|
|
["wss://[email protected]/moq", "ws"],
|
|
// User information on the page's own host is refused too.
|
|
["https://user:[email protected]/room-api", "http"],
|
|
]) {
|
|
const r = taken(v, kind);
|
|
assert.equal(r.ok, false, `${v} is refused`);
|
|
assert.ok(r.reason, `${v} has a reason`);
|
|
}
|
|
});
|
|
|
|
test("a scheme that is no endpoint's is refused: javascript:, data:, a plain relay on a secure page", () => {
|
|
assert.equal(taken("javascript:alert(document.domain)", "http").ok, false);
|
|
assert.equal(taken("javascript:alert(document.domain)", "ws").ok, false);
|
|
assert.equal(taken("data:text/html,<p>x</p>", "http").ok, false);
|
|
assert.equal(taken("ws://wai.transaction.science/moq", "ws").ok, false, "a secure page takes wss: alone");
|
|
assert.equal(taken("http://wai.transaction.science/room-api", "http").ok, false, "a secure page takes https: alone");
|
|
assert.equal(taken("wss://wai.transaction.science/moq", "http").ok, false, "a relay URI is no fetch endpoint");
|
|
assert.equal(taken("https://wai.transaction.science/moq", "ws").ok, false, "a web URI is no WebSocket endpoint");
|
|
});
|
|
|
|
test("a page served over plain HTTP from a public host takes nothing", () => {
|
|
assert.equal(taken("/room-api", "http", "http://wai.transaction.science/room/").ok, false);
|
|
assert.equal(taken("ws://wai.transaction.science/moq", "ws", "http://wai.transaction.science/room/").ok, false);
|
|
});
|
|
|
|
test("a loopback page, as the end-to-end harness serves it, takes loopback endpoints on any port and nothing else", () => {
|
|
for (const page of ["http://127.0.0.1:41234/room/", "http://localhost:8080/room/", "http://[::1]:9000/room/"]) {
|
|
assert.equal(taken("ws://127.0.0.1:51001/moq", "ws", page).ok, true, page);
|
|
assert.equal(taken("https://127.0.0.1:51002/moq", "http", page).ok, true, page);
|
|
assert.equal(taken("http://localhost:51003/issuer", "http", page).ok, true, page);
|
|
assert.equal(taken("/fixture.json", "http", page).ok, true, page);
|
|
assert.equal(taken("wss://evil.example/moq", "ws", page).ok, false, page);
|
|
assert.equal(taken("//evil.example/room-api", "http", page).ok, false, page);
|
|
assert.equal(taken("http://[email protected]/", "http", page).ok, false, page);
|
|
assert.equal(taken("javascript:alert(1)", "http", page).ok, false, page);
|
|
}
|
|
});
|
|
|
|
test("a GOAWAY's URI is checked by its own scheme", () => {
|
|
assert.equal(kindOf("wss://wai.transaction.science/moq"), "ws");
|
|
assert.equal(kindOf("https://wai.transaction.science/moq"), "http");
|
|
assert.equal(taken("wss://relay.evil.example/moq", kindOf("wss://relay.evil.example/moq")).ok, false);
|
|
assert.equal(taken("wss://wai.transaction.science/moq-b", kindOf("wss://wai.transaction.science/moq-b")).ok, true);
|
|
});
|