/** * liveServerAllowList — extract of the host/origin allow-list logic from * `src/server/ws/liveServer.ts`. * * Lives in its own module so unit tests can exercise it without booting the * full WebSocket server. The behaviour here MUST match the one used by the * connection handler exactly. * * Bug #1 (plans/2026-06-23-omniroute-v3.8.34-deep-audit.md) added the * `LIVE_WS_ALLOWED_HOSTS` opt-in for LAN/Tailscale deployments. */ const DEFAULT_HOST = "127.0.0.1"; /** * Default origins allowed to open a WebSocket against the local dashboard. * These match the loopback HTTP listener at port 20128. */ export const DEFAULT_ALLOWED_ORIGINS: readonly string[] = Object.freeze([ "http://127.0.0.1:20128", "http://localhost:20128", "http://[::1]:20128", ]); /** * Parse a comma-separated env value into a set of trimmed, non-empty entries. * Centralized so tests can exercise empty / whitespace / dup behaviour. */ export function parseCsvEnv(value: string | undefined | null): Set { return new Set( (value ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean) ); } /** * Build the static origin allow-list from defaults + LIVE_WS_ALLOWED_ORIGINS. */ export function buildAllowedOrigins(env: NodeJS.ProcessEnv = process.env): Set { const extra = parseCsvEnv(env.LIVE_WS_ALLOWED_ORIGINS); return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]); } /** * Build the host-based allow-list (LAN/Tailscale extension). */ export function buildAllowedHosts(env: NodeJS.ProcessEnv = process.env): Set { return parseCsvEnv(env.LIVE_WS_ALLOWED_HOSTS); } /** * Parse the host portion of an Origin URL. * * Returns `null` when the input is not a well-formed absolute URL — callers * should treat `null` as "not a match". */ export function originHost(origin: string): { host: string; hostname: string } | null { try { const url = new URL(origin); return { host: url.host, hostname: url.hostname }; } catch { return null; } } /** * Whether the given Origin's host (or `host:port`) is in the host * allow-list. Returns false when the list is empty. */ export function originHostMatches(origin: string, allowedHosts: Set): boolean { if (allowedHosts.size === 0) return false; const parsed = originHost(origin); if (!parsed) return false; return allowedHosts.has(parsed.host) || allowedHosts.has(parsed.hostname); } /** * Top-level Origin allow decision. The contract: * * - When `origin` is undefined (no Origin header, e.g. CLI/MCP), we only * accept the request when the WS listener is bound to loopback. This * prevents drive-by LAN clients from omitting Origin to bypass the * browser-side check. * * - When `origin` is present, we accept it if it matches an entry in the * static origin list (defaults + LIVE_WS_ALLOWED_ORIGINS) or if its * host matches an entry in the LAN allow-list (LIVE_WS_ALLOWED_HOSTS). */ export function isOriginAllowed( origin: string | undefined, env: NodeJS.ProcessEnv = process.env, options: { allowedOrigins?: Set; allowedHosts?: Set } = {} ): boolean { const allowedOrigins = options.allowedOrigins ?? buildAllowedOrigins(env); const allowedHosts = options.allowedHosts ?? buildAllowedHosts(env); if (!origin) { const host = env.LIVE_WS_HOST || DEFAULT_HOST; return host === "127.0.0.1" || host === "::1" || host === "localhost"; } if (allowedOrigins.has(origin)) return true; if (originHostMatches(origin, allowedHosts)) return true; return false; }