Files
OmniRoute/tests/unit/security/live-server-allowlist.test.ts
ggdayup d715190bb0 fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic (#13533)
* fix(runtime): eliminate hardcoded 20128 port remnants and make loopback URLs dynamic

- Make model assessment probe base URL resolve dynamically from getRuntimePorts() / env
- Support dynamic loopback in traffic inspector replay route and MITM handlers
- Make WebSocket live server allowlist dynamically include runtime PORT/DASHBOARD_PORT loopback origins
- Update CLI tools config/apply/letta-settings and tool-detector to adapt to configured runtime port
- Update client UI components (EndpointPageClient, ApiExplorerClient, RelayProxyClient) to use current window origin or dynamic port
- Make resolveOmniRouteBaseUrl, useDisplayBaseUrl, and wellKnown.ts respect configured port
- Update package.json electron:dev wait-on to use ${PORT:-20128}
- Add test coverage for custom port in resolveOmniRouteBaseUrl and liveServerAllowList
- Add changelog fragment for PR #13533

* fix(runtime): complete the truncated fallback comment in wellKnown.ts

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-19 00:04:02 -03:00

198 lines
7.3 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);
});
it("includes dynamic loopback origins when custom PORT is configured", () => {
const env = {
...EMPTY_ENV,
PORT: "37128",
};
const out = buildAllowedOrigins(env);
assert.equal(out.has("http://localhost:37128"), true);
assert.equal(out.has("http://127.0.0.1:37128"), true);
assert.equal(out.has("http://[::1]:37128"), true);
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 treat a wildcard host as an allow-all origin policy", () => {
const env = { ...EMPTY_ENV, LIVE_WS_ALLOWED_HOSTS: "*" };
assert.equal(isOriginAllowed("http://100.90.139.116:37128", env), false);
});
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);
});
});