ai_api / src /server /ws /liveServerAllowList.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
3.59 kB
/**
* 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<string> {
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<string> {
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<string> {
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<string>): 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<string>; allowedHosts?: Set<string> } = {}
): 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;
}