178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|