Files
2026-07-13 13:39:12 +08:00

178 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:testcollected 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);
});
});