diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 284d765dfc..6cf1c6aa37 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -12,7 +12,7 @@ import { isFatalInstrumentationHookFailure, formatAndroidInstrumentationFailureHint, } from "../utils/ensureAndroidCacheDir.mjs"; -import { resolveServerHost } from "../utils/serverHost.mjs"; +import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs"; import { resolveMaxOldSpaceMb, calibrateHeapFallbackMb, @@ -132,6 +132,15 @@ export async function runServe(opts = {}) { `); } + // GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a + // deliberate local-first choice, but it must be loud at startup — an operator + // on an untrusted network learns the two escape hatches here, not after a + // surprise quota bill. + const exposureWarning = resolveExposureWarning(); + if (exposureWarning) { + console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`); + } + const serverWsJs = join(APP_DIR, "server-ws.mjs"); const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js"); diff --git a/bin/cli/utils/serverHost.mjs b/bin/cli/utils/serverHost.mjs index a64a88d2a6..a13082612f 100644 --- a/bin/cli/utils/serverHost.mjs +++ b/bin/cli/utils/serverHost.mjs @@ -24,3 +24,34 @@ export function resolveServerHost( } return "0.0.0.0"; } + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + +/** + * Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds + * all interfaces while the inference plane requires no credentials, so any + * LAN peer can spend the operator's quota. That local-first posture is a + * deliberate, documented default — but it must be LOUD at startup so an + * operator who never read the docs still learns the two escape hatches. + * + * Returns the warning text when the server will listen on a non-loopback + * interface with no API-key requirement, or null when the exposure is closed. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {string} [host] + * @returns {string | null} + */ +export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) { + if (LOOPBACK_HOSTS.has(host)) return null; + const requireKey = String(env.REQUIRE_API_KEY || "") + .trim() + .toLowerCase(); + if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null; + return ( + `SECURITY: listening on ${host} with NO API-key requirement — the inference ` + + `plane (/v1/*) is reachable by ANY device that can route to this host, and ` + + `requests are billed to your configured providers. This local-first default ` + + `is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` + + `or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.` + ); +} diff --git a/tests/unit/cli-serve-hostname.test.ts b/tests/unit/cli-serve-hostname.test.ts index 377e7eed38..9b801e2baa 100644 --- a/tests/unit/cli-serve-hostname.test.ts +++ b/tests/unit/cli-serve-hostname.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { resolveServerHost } from "../../bin/cli/utils/serverHost.mjs"; +import { resolveServerHost, resolveExposureWarning } from "../../bin/cli/utils/serverHost.mjs"; test("serve hostname: Linux honors OMNIROUTE_SERVER_HOST when HOSTNAME is set", () => { assert.equal( @@ -55,3 +55,26 @@ test("serve hostname: Windows preserves an explicit legacy HOSTNAME", () => { test("serve hostname: Windows ignores an auto-set HOSTNAME matching the machine", () => { assert.equal(resolveServerHost({ HOSTNAME: "windows-pc" }, "win32", "windows-pc"), "0.0.0.0"); }); + +test("exposure warning: fires when bound to all interfaces with no API-key requirement (GHSA-wmgv-ph3p-rv57)", () => { + const warning = resolveExposureWarning({}, "0.0.0.0"); + assert.ok(warning, "a warning must be returned for the shipped default posture"); + assert.match(warning, /REQUIRE_API_KEY/); + assert.match(warning, /OMNIROUTE_SERVER_HOST/); +}); + +test("exposure warning: silent when REQUIRE_API_KEY is enabled", () => { + assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "true" }, "0.0.0.0"), null); + assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "1" }, "0.0.0.0"), null); +}); + +test("exposure warning: silent on loopback binds", () => { + assert.equal(resolveExposureWarning({}, "127.0.0.1"), null); + assert.equal(resolveExposureWarning({}, "localhost"), null); + assert.equal(resolveExposureWarning({}, "::1"), null); +}); + +test("exposure warning: fires for a LAN bind too (any non-loopback interface)", () => { + assert.ok(resolveExposureWarning({}, "192.168.0.17")); + assert.ok(resolveExposureWarning({}, "::")); +});