feat(cli): boot exposure warning for unauthenticated LAN bind (GHSA-wmgv-ph3p-rv57)

The shipped default (bind 0.0.0.0 + no API-key requirement) is a deliberate,
documented local-first posture — but an operator on an untrusted network
should learn that at startup, not after a surprise quota bill. serve now
prints a loud warning naming both escape hatches (REQUIRE_API_KEY=true or
OMNIROUTE_SERVER_HOST=127.0.0.1) whenever the resolved bind is non-loopback
and no key is required. Silent on loopback binds and when REQUIRE_API_KEY is
enabled. The default posture itself is unchanged (operator decision).
This commit is contained in:
Xiangzhe
2026-08-23 12:50:42 -03:00
parent 0fb4eb6878
commit ac206e9375
3 changed files with 65 additions and 2 deletions

View File

@@ -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");

View File

@@ -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.`
);
}

View File

@@ -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({}, "::"));
});