Files
OmniRoute/tests/unit/security/live-server-allowlist.test.ts
Mina_ 12986c44c9 fix(live-ws, dashboard): allow 0.0.0.0 dashboard origin and stop non-square SVG image warnings (#11269)
Validated on the combined 8-PR board: live-server-allowlist suite green within the board's 88/88, typecheck:core + dashboard-typecheck clean. 0.0.0.0 as a browser Origin is loopback-equivalent (it can never name a LAN host — same treatment as headroom/detect.ts and outboundUrlGuard.ts), so the local-only posture is unchanged and the FORBIDDEN_ORIGIN reconnect flood on the dev server's printed URL stops. Thank you @Minamaged18!
2026-08-23 18:10:57 -03:00

181 lines
6.7 KiB
TypeScript
Raw 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);
// 0.0.0.0 is loopback-equivalent in the browser; the dashboard is often
// opened at http://0.0.0.0:20128, which sends exactly that Origin on WS.
assert.equal(isOriginAllowed("http://0.0.0.0: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);
});
});