open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.test.mjs
Transaction Science cb766e7166 room page: take the endpoints a link names only on the page's own origin
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]>
2026-10-22 12:38:31 -04:00

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