/** * Unit tests for `liveServerAllowList`. * * Bug #1 (plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) introduced the * `LIVE_WS_ALLOWED_HOSTS` opt-in for LAN/Tailscale deployments. These tests * pin down the contract: defaults remain loopback-only; the env var extends * the allow-list with bare hostnames or `host:port` pairs; the absence of * Origin is still only acceptable on loopback. * * Runner note: lives under tests/unit/security/ (a node:test–collected subdir) * and uses node:test + assert/strict so the gate `check:test-discovery` actually * runs it. The original copy under tests/unit/server/ used vitest in a path no * runner collected — it never executed (caught by Gate 6A.1). */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { buildAllowedOrigins, buildAllowedHosts, isOriginAllowed, originHost, originHostMatches, parseCsvEnv, DEFAULT_ALLOWED_ORIGINS, } from "@/server/ws/liveServerAllowList"; const EMPTY_ENV: NodeJS.ProcessEnv = {}; describe("parseCsvEnv", () => { it("returns empty set for undefined", () => { assert.equal(parseCsvEnv(undefined).size, 0); }); it("returns empty set for empty string", () => { assert.equal(parseCsvEnv("").size, 0); }); it("trims whitespace and drops empty entries", () => { const out = parseCsvEnv(" a , b ,, c "); assert.deepEqual([...out], ["a", "b", "c"]); }); it("deduplicates entries", () => { const out = parseCsvEnv("a,a,b"); assert.deepEqual([...out], ["a", "b"]); }); }); describe("buildAllowedOrigins", () => { it("includes the loopback defaults", () => { const out = buildAllowedOrigins(EMPTY_ENV); for (const origin of DEFAULT_ALLOWED_ORIGINS) { assert.equal(out.has(origin), true); } }); it("extends defaults with LIVE_WS_ALLOWED_ORIGINS", () => { const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com,https://other.example.com", }; const out = buildAllowedOrigins(env); assert.equal(out.has("https://dash.example.com"), true); assert.equal(out.has("https://other.example.com"), true); // Defaults remain. assert.equal(out.has("http://localhost:20128"), true); }); }); describe("buildAllowedHosts", () => { it("returns empty set when env is not set", () => { assert.equal(buildAllowedHosts(EMPTY_ENV).size, 0); }); it("parses comma-separated hosts", () => { const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160,desktop.tailnet.ts.net" }; const out = buildAllowedHosts(env); assert.equal(out.has("100.96.135.160"), true); assert.equal(out.has("desktop.tailnet.ts.net"), true); }); }); describe("originHost", () => { it("returns host and hostname for a valid URL", () => { assert.deepEqual(originHost("http://100.96.135.160:20128"), { host: "100.96.135.160:20128", hostname: "100.96.135.160", }); }); it("returns null for an invalid URL", () => { assert.equal(originHost("not a url"), null); }); }); describe("originHostMatches", () => { it("returns false when the allow-list is empty", () => { assert.equal(originHostMatches("http://100.96.135.160:20128", new Set()), false); }); it("matches by exact host:port", () => { const allow = new Set(["100.96.135.160:20128"]); assert.equal(originHostMatches("http://100.96.135.160:20128", allow), true); }); it("matches by bare hostname regardless of port", () => { const allow = new Set(["100.96.135.160"]); assert.equal(originHostMatches("http://100.96.135.160:20128", allow), true); assert.equal(originHostMatches("http://100.96.135.160:55555", allow), true); }); it("returns false for a non-matching host", () => { const allow = new Set(["100.96.135.160"]); assert.equal(originHostMatches("http://10.0.0.5:20128", allow), false); }); it("returns false for an unparseable origin", () => { const allow = new Set(["100.96.135.160"]); assert.equal(originHostMatches("not-a-url", allow), false); }); }); describe("isOriginAllowed", () => { it("rejects any non-loopback origin by default", () => { assert.equal(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV), false); }); it("accepts the default loopback origins", () => { assert.equal(isOriginAllowed("http://127.0.0.1:20128", EMPTY_ENV), true); assert.equal(isOriginAllowed("http://localhost:20128", EMPTY_ENV), true); assert.equal(isOriginAllowed("http://[::1]:20128", EMPTY_ENV), true); }); it("accepts an Origin matching LIVE_WS_ALLOWED_ORIGINS", () => { const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_ORIGINS: "https://dash.example.com" }; assert.equal(isOriginAllowed("https://dash.example.com", env), true); }); it("accepts a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is set", () => { const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160" }; assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true); }); it("accepts a Tailscale Origin matched by host:port when LIVE_WS_ALLOWED_HOSTS is set", () => { const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "100.96.135.160:20128" }; assert.equal(isOriginAllowed("http://100.96.135.160:20128", env), true); }); it("does NOT accept a Tailscale Origin when LIVE_WS_ALLOWED_HOSTS is unset", () => { // Critical security invariant: without explicit opt-in, the LAN/Tailscale // surface is closed even though the listener is reachable. assert.equal(isOriginAllowed("http://100.96.135.160:20128", EMPTY_ENV), false); }); it("rejects a missing Origin when bound to LAN (0.0.0.0)", () => { // A CLI client that omits Origin should NOT be accepted when the // operator opted into LAN exposure. Browsers always send Origin, so the // empty-Origin path is for non-browser callers; the security stance is // "refuse unless loopback". const env = { ...EMPTY_ENV, LIVE_WS_HOST: "0.0.0.0" }; assert.equal(isOriginAllowed(undefined, env), false); }); it("accepts a missing Origin on loopback (CLI/MCP)", () => { // CLI/MCP clients running on the same host omit Origin. The default // listener (127.0.0.1) accepts them. assert.equal(isOriginAllowed(undefined, EMPTY_ENV), true); }); it("accepts a missing Origin on ::1 / localhost hosts", () => { const env1 = { ...EMPTY_ENV, LIVE_WS_HOST: "::1" }; assert.equal(isOriginAllowed(undefined, env1), true); const env2 = { ...EMPTY_ENV, LIVE_WS_HOST: "localhost" }; assert.equal(isOriginAllowed(undefined, env2), true); }); });