From b45e0c69e6e7f3ab26ace2dac075b2525a7fd5c6 Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Fri, 18 Sep 2026 07:33:39 -0700 Subject: [PATCH] feat(security): warn at boot when the inference server is exposed anonymously (#13820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(security): warn at boot when the inference server is exposed anonymously `GET /v1/models` follows the dashboard login posture (`isAuthRequired()` / `requireAuthForModels`) while the inference routes follow `REQUIRE_API_KEY`. On an instance with an admin password set and `REQUIRE_API_KEY=false`, `/v1/models` answers 401 while `/v1/responses` is open to anyone who can reach the port — so the most natural probe an operator runs reports the opposite of the truth. #12568 added a boot warning for exactly this combination, but wired it only into the API bridge and the live dashboard WebSocket. The Next server that actually answers `/v1/chat/completions` and `/v1/responses` never reached it, and it is the one that binds every interface by default (`process.env.HOST || "0.0.0.0"`). Wire the existing guard into the Next boot hook, and document the split. Resolving the bound host needed care: two entrypoints bind that server and they read different variables. `run-next.mjs` honours `HOST`; the Docker entrypoint delegates to Next's generated `server.js`, which reads `HOSTNAME`. `run-next.mjs` now publishes what it actually binds as `OMNIROUTE_BOUND_HOST`, and the guard reads that, then `HOSTNAME`, then the shared `0.0.0.0` default. `HOST` is deliberately absent from the chain: the standalone server ignores it, so consulting it there would warn about an interface the server is not on — and one false warning teaches an operator to ignore the next one. Closes #13695 * docs(changelog): add changelog.d entry for #13820 --- ...820-inference-auth-posture-boot-warning.md | 1 + docs/reference/ENVIRONMENT.md | 2 +- docs/security/INFERENCE_AUTH_POSTURE.md | 67 +++++++ docs/security/meta.json | 1 + scripts/check/check-env-doc-sync.mjs | 4 + scripts/dev/run-next.mjs | 11 +- src/instrumentation-node.ts | 24 ++- src/lib/startup/nonLoopbackApiKeyGuard.ts | 40 ++++ .../unit/inference-auth-posture-13695.test.ts | 177 ++++++++++++++++++ 9 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 changelog.d/features/13820-inference-auth-posture-boot-warning.md create mode 100644 docs/security/INFERENCE_AUTH_POSTURE.md create mode 100644 tests/unit/inference-auth-posture-13695.test.ts diff --git a/changelog.d/features/13820-inference-auth-posture-boot-warning.md b/changelog.d/features/13820-inference-auth-posture-boot-warning.md new file mode 100644 index 0000000000..88aeacaf55 --- /dev/null +++ b/changelog.d/features/13820-inference-auth-posture-boot-warning.md @@ -0,0 +1 @@ +- **feat(security):** OmniRoute now warns at boot when the server that answers `/v1` inference is bound to a non-loopback interface while `REQUIRE_API_KEY` is disabled. The guard added in [#12568](https://github.com/diegosouzapw/OmniRoute/pull/12568) covered the API bridge (`API_HOST`, default loopback) and the live dashboard WebSocket, but not the Next server that actually serves `/v1/chat/completions` and `/v1/responses` — which binds `HOST || 0.0.0.0`, every interface by default. That matters because `GET /v1/models` follows the dashboard login posture (`requireAuthForModels`) while inference follows `REQUIRE_API_KEY`, so an instance with an admin password and `REQUIRE_API_KEY=false` answers `401` to the probe an operator naturally runs while inference stays open to anyone who can reach the port. The bound host is resolved from `OMNIROUTE_BOUND_HOST` (published by `scripts/dev/run-next.mjs`) then Next's own `HOSTNAME` (the Docker path); `HOST` is deliberately excluded because the standalone server ignores it and a warning naming the wrong interface is worse than none. New `docs/security/INFERENCE_AUTH_POSTURE.md` documents the split, how to actually probe inference, and the [#2257](https://github.com/diegosouzapw/OmniRoute/issues/2257) caveat that an invalid bearer degrades to anonymous. ([#13820](https://github.com/diegosouzapw/OmniRoute/pull/13820)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 40a0ca25c3..7906d8712a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -219,7 +219,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `MACHINE_ID_SALT` | `endpoint-proxy-salt` | `src/lib/auth` | Salt combined with hardware identifiers for machine fingerprinting. Change per-deployment for isolation. | | `OMNIROUTE_CLI_SALT` | _(unset = random per-install salt persisted at `/cli-token-salt.json`)_ | `src/lib/machineToken.ts` | HMAC salt for deriving the local CLI auth token. Setting this value rotates all CLI tokens on the machine and always takes priority over the persisted salt. See `docs/security/CLI_TOKEN.md`. | | `AUTH_COOKIE_SECURE` | `false` | `src/lib/auth` | Sets the `Secure` flag on session cookies. **Must be `true`** when running behind HTTPS. | -| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. | +| `REQUIRE_API_KEY` | `false` | API middleware | When `true`, all `/v1/*` proxy requests must include a valid API key. **This flag does not gate `GET /v1/models`**, which follows the dashboard login posture (`requireAuthForModels`) instead — so a `401` from `/v1/models` does NOT mean inference is protected. See `docs/security/INFERENCE_AUTH_POSTURE.md` (#13695). | | `ALLOW_API_KEY_REVEAL` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allows revealing full API key values in the Dashboard UI. Configurable from Dashboard Feature Flags; security risk on shared instances. | | `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | | `DEFAULT_RATE_LIMIT_PER_DAY` | _(unset = unlimited)_ | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Unset or empty: no implicit cap (#2289, #11017). `0` is the same (unlimited). Positive integer N enables N/day, 5N/week, 20N/month. Malformed non-empty values fall back to the legacy 1000/day, 5000/week, 20000/month windows. | diff --git a/docs/security/INFERENCE_AUTH_POSTURE.md b/docs/security/INFERENCE_AUTH_POSTURE.md new file mode 100644 index 0000000000..c808bf010f --- /dev/null +++ b/docs/security/INFERENCE_AUTH_POSTURE.md @@ -0,0 +1,67 @@ +--- +title: "Inference Auth Posture" +--- + +# Inference Auth Posture + +## Overview + +`GET /v1/models` and the inference endpoints are gated by **different +settings**. The most natural probe an operator runs to answer "is my API +protected?" can therefore return the wrong answer. + +| Endpoint | Gated by | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /v1/models` | The dashboard login posture — `isAuthRequired()`, overridable per-instance with `requireAuthForModels` (`src/app/api/v1/models/catalogRequest.ts`) | +| `POST /v1/chat/completions`, `POST /v1/responses`, and the other client API routes | `REQUIRE_API_KEY` (`src/server/authz/policies/clientApi.ts`) | + +On an instance that has an admin password set and `REQUIRE_API_KEY=false`, +`/v1/models` answers `401` while inference is open to anyone who can reach the +port. + +> **Probing caveat:** a `401` from `/v1/models` does **not** verify that +> inference is protected. It only tells you the dashboard requires login. + +This is not hypothetical. In discussion #13310 a self-hosted instance behind +Traefik had its Codex quota spent by anonymous `POST /v1/responses` traffic +while `/v1/models` returned `401` — which, in the reporter's words, "initially +created the impression that the entire API was protected" and sent them +investigating the wrong component. + +## How to actually check + +Probe an inference route, not the catalog: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + -X POST http://:/v1/chat/completions \ + -H 'content-type: application/json' \ + -d '{"model":"","messages":[{"role":"user","content":"ping"}]}' +``` + +A `401` means `REQUIRE_API_KEY` is enforcing. Anything that reaches routing +(including a model-not-found error) means anonymous traffic is accepted. + +Note that with `REQUIRE_API_KEY=false` an **invalid** bearer degrades to +anonymous rather than rejecting (#2257), so sending a junk key is not a valid +probe either. + +## Startup warning + +When `REQUIRE_API_KEY` is disabled and a server binds a non-loopback interface, +OmniRoute logs a warning at boot (`src/lib/startup/nonLoopbackApiKeyGuard.ts`). +This covers three surfaces: + +- the Dashboard/API server that serves `/v1` inference — `HOST`, default `0.0.0.0` +- the API bridge — `API_HOST`, default `127.0.0.1` +- the live dashboard WebSocket — its own host + +The warning never blocks boot: a reverse proxy in front of OmniRoute may +already be enforcing its own authentication. + +## Related + +- `REQUIRE_API_KEY` in [ENVIRONMENT.md](../reference/ENVIRONMENT.md) +- `APP_BIND_HOST` and the compose loopback defaults (#12568) +- #2257 — invalid bearer degrades to anonymous when `REQUIRE_API_KEY` is off +- #13695 — this document diff --git a/docs/security/meta.json b/docs/security/meta.json index cc28c2bbb1..b0141606d6 100644 --- a/docs/security/meta.json +++ b/docs/security/meta.json @@ -3,6 +3,7 @@ "pages": [ "GUARDRAILS", "ERROR_SANITIZATION", + "INFERENCE_AUTH_POSTURE", "ROUTE_GUARD_TIERS", "BAN_DETECTION", "AGENTROUTER_WAF", diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 272ae00b72..a91748b6ed 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -203,6 +203,10 @@ const IGNORE_FROM_CODE = new Set([ // Listener-owned self-fetch transport signal. The HTTP/HTTPS launchers set // this before application imports; it is not user-configurable product env. "OMNIROUTE_INTERNAL_SCHEME", + // Runner-owned bind-host signal. scripts/dev/run-next.mjs publishes the + // interface it actually binds so the in-process startup guard can name it + // (#13695); operators configure HOST / HOSTNAME, never this. + "OMNIROUTE_BOUND_HOST", // Source typo / placeholder. "OMNIROUT", // Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH). diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index cbc8888e12..80ca2b3d31 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -16,10 +16,7 @@ import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopack import { randomUUID } from "node:crypto"; import { getMainServerTimeoutConfig } from "./main-server-timeouts.mjs"; import { createSystemdNotifier } from "./systemd-notify.mjs"; -import { - attachRequestStreamGuards, - installProcessCrashGuard, -} from "./httpClientAbortGuard.mjs"; +import { attachRequestStreamGuards, installProcessCrashGuard } from "./httpClientAbortGuard.mjs"; const { maybeHandleDisallowedMethod } = methodGuard; const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard; @@ -104,6 +101,12 @@ process.env.OMNIROUTE_INTERNAL_SCHEME = "http"; const { dashboardPort } = runtimePorts; const hostname = process.env.HOST || "0.0.0.0"; +// Publish the interface this server actually binds so in-process TypeScript +// (src/lib/startup/nonLoopbackApiKeyGuard.ts) can warn about an exposed +// anonymous /v1 without re-deriving it. The standalone/Docker entrypoint +// (scripts/dev/run-standalone.mjs -> Next's own server.js) uses HOSTNAME +// instead, which the guard falls back to. #13695 +process.env.OMNIROUTE_BOUND_HOST = hostname; // Turbopack by default in dev (matches the Next 16 CLI default and the production // build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the // webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable, diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index e3ae959118..b8bfda2293 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -314,7 +314,7 @@ export async function registerQuotaFetchers(): Promise { id: typeof node.id === "string" ? node.id : null, prefix: typeof node.prefix === "string" ? node.prefix : null, baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, - })), + })) ); } catch (error) { console.warn("[STARTUP] Moonshot custom-node fetcher scan skipped:", error); @@ -334,6 +334,14 @@ export async function registerNodejs(): Promise { // of the generic "next-server" standalone server name. process.title = renameProcessTitle(process.title); + // #13695: the inference API and `/v1/models` follow DIFFERENT auth settings, + // so `GET /v1/models` answering 401 does not mean inference is protected. + // #12568 added this warning for the API bridge and live-WS servers, but not + // for the Next server that actually answers `/v1/chat/completions` and + // `/v1/responses` — and that one binds every interface by default. Runs + // before the DB work below so it is not buried under the boot log. + (await import("@/lib/startup/nonLoopbackApiKeyGuard")).warnIfInferenceServerExposed(); + // Initialize proxy fetch patch FIRST (before any HTTP requests) await import("@omniroute/open-sse/utils/proxyFetch.ts"); console.log("[STARTUP] Global fetch proxy patch initialized"); @@ -630,12 +638,14 @@ export async function registerNodejs(): Promise { // Conductor bridge (PRD Conductor RF1): mirrors OmniConductor hub tasks into the // A2A TaskManager via the hub SSE. Opt-in — self-gated on CONDUCTOR_HUB_URL. - import("@/lib/conductor/boot").then((m) => { - if (m.initConductorBridge()) console.log("[STARTUP] Conductor bridge started"); - }).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[STARTUP] Conductor bridge failed to start (non-fatal):", msg); - }), + import("@/lib/conductor/boot") + .then((m) => { + if (m.initConductorBridge()) console.log("[STARTUP] Conductor bridge started"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Conductor bridge failed to start (non-fatal):", msg); + }), // Proactive connection-cooldown recovery (#8): re-validate connections whose // transient `rate_limited_until` window has elapsed OUTSIDE the request hot path, diff --git a/src/lib/startup/nonLoopbackApiKeyGuard.ts b/src/lib/startup/nonLoopbackApiKeyGuard.ts index ceaf5c3b7f..ba5bb3dcd8 100644 --- a/src/lib/startup/nonLoopbackApiKeyGuard.ts +++ b/src/lib/startup/nonLoopbackApiKeyGuard.ts @@ -34,3 +34,43 @@ export function warnIfNonLoopbackWithoutApiKey(serverLabel: string, host: string "already enforces its own authentication." ); } + +/** + * Host the Next server that answers `/v1` inference is bound to. + * + * Two entrypoints bind that server and they do NOT read the same variable: + * + * - `scripts/dev/run-next.mjs` (`npm run dev` / `npm start`) uses + * `process.env.HOST`, and publishes the resolved value as + * `OMNIROUTE_BOUND_HOST` for exactly this lookup. + * - `scripts/dev/run-standalone.mjs` (the Docker entrypoint) delegates to + * Next's generated `server.js`, which uses Next's own `HOSTNAME` + * convention — `Dockerfile` sets `HOSTNAME=0.0.0.0`. + * + * `HOST` is deliberately NOT in this chain. The only path that honours it is + * run-next.mjs, which has already folded it into `OMNIROUTE_BOUND_HOST`; on + * the standalone path Next ignores `HOST` and binds `HOSTNAME`, so consulting + * it there would name an interface the server is not on. A warning that + * fingers the wrong interface is worse than none — an operator who sees one + * false warning stops reading the next one. + * + * Both entrypoints default to every interface, so the fallback does too. + */ +export const MAIN_SERVER_DEFAULT_HOST = "0.0.0.0"; + +export function resolveMainServerHost(): string { + return process.env.OMNIROUTE_BOUND_HOST || process.env.HOSTNAME || MAIN_SERVER_DEFAULT_HOST; +} + +/** + * Warn when the inference-serving Next server is reachable off-box without an + * API key. Separate from the API bridge / live-WS call sites so the log names + * the surface an operator actually probes — `/v1/models` answering 401 says + * nothing about whether inference is protected (#13695). + */ +export function warnIfInferenceServerExposed(): void { + warnIfNonLoopbackWithoutApiKey( + "Dashboard/API server (serves /v1 inference)", + resolveMainServerHost() + ); +} diff --git a/tests/unit/inference-auth-posture-13695.test.ts b/tests/unit/inference-auth-posture-13695.test.ts new file mode 100644 index 0000000000..5d1348f310 --- /dev/null +++ b/tests/unit/inference-auth-posture-13695.test.ts @@ -0,0 +1,177 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +import { + MAIN_SERVER_DEFAULT_HOST, + resolveMainServerHost, + warnIfInferenceServerExposed, +} from "@/lib/startup/nonLoopbackApiKeyGuard"; + +// #13695: `/v1/models` follows the dashboard login posture while inference +// follows REQUIRE_API_KEY, so probing `/v1/models` can report "protected" for +// an instance whose `/v1/responses` is open to anyone who can reach the port. +// #12568 added the boot warning for the API bridge and live-WS servers; the +// Next server that actually answers inference was left uncovered, and it is +// the one that binds every interface by default. + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); + +// HOSTNAME is exported by the shell on many Linux boxes, so a test that only +// clears HOST would read the machine name and pass for the wrong reason. +const CLEAR_HOST = { + OMNIROUTE_BOUND_HOST: undefined, + HOSTNAME: undefined, + HOST: undefined, +} satisfies Record; + +function withEnv(vars: Record, fn: () => T): T { + const prev: Record = {}; + for (const key of Object.keys(vars)) { + prev[key] = process.env[key]; + const value = vars[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const key of Object.keys(prev)) { + const value = prev[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function captureWarn(fn: () => void): string[] { + const messages: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + messages.push(args.map(String).join(" ")); + }; + try { + fn(); + } finally { + console.warn = original; + } + return messages; +} + +test("inference server warns on its default bind with REQUIRE_API_KEY unset", () => { + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: undefined }, () => { + const messages = captureWarn(warnIfInferenceServerExposed); + assert.equal(messages.length, 1); + assert.match(messages[0], /\/v1 inference/); + assert.match(messages[0], /non-loopback host "0\.0\.0\.0"/); + assert.match(messages[0], /REQUIRE_API_KEY/); + }); +}); + +test("inference server warns on an explicit LAN bind with REQUIRE_API_KEY=false", () => { + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: "false", OMNIROUTE_BOUND_HOST: "192.168.1.5" }, () => { + const messages = captureWarn(warnIfInferenceServerExposed); + assert.equal(messages.length, 1); + assert.match(messages[0], /non-loopback host "192\.168\.1\.5"/); + }); +}); + +test("inference server stays silent on loopback, and on 0.0.0.0 with the key required", () => { + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: "false", OMNIROUTE_BOUND_HOST: "127.0.0.1" }, () => { + assert.deepEqual(captureWarn(warnIfInferenceServerExposed), []); + }); + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: "false", OMNIROUTE_BOUND_HOST: "::1" }, () => { + assert.deepEqual(captureWarn(warnIfInferenceServerExposed), []); + }); + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: "true" }, () => { + assert.deepEqual(captureWarn(warnIfInferenceServerExposed), []); + }); +}); + +test("host resolution tracks both entrypoints that bind the inference server", () => { + // The two runners are dependency-free and boot before any TypeScript, so + // neither can import the guard. run-next.mjs publishes what it binds; + // the standalone/Docker path goes through Next's own HOSTNAME convention. + // If either contract moves, the warning names an interface the server is + // not on — and one false warning is enough to train an operator to ignore + // the next one. + const runner = fs.readFileSync(path.join(REPO_ROOT, "scripts/dev/run-next.mjs"), "utf8"); + const match = runner.match(/const hostname = process\.env\.HOST \|\| "([^"]+)"/); + assert.ok(match, 'run-next.mjs no longer resolves its bind host as `process.env.HOST || "..."`'); + assert.equal(match[1], MAIN_SERVER_DEFAULT_HOST); + assert.match( + runner, + /process\.env\.OMNIROUTE_BOUND_HOST = hostname;/, + "run-next.mjs no longer publishes the host it binds" + ); + + const dockerfile = fs.readFileSync(path.join(REPO_ROOT, "Dockerfile"), "utf8"); + assert.match( + dockerfile, + /^ENV HOSTNAME=0\.0\.0\.0$/m, + "Dockerfile no longer sets the HOSTNAME the standalone server binds" + ); + + withEnv(CLEAR_HOST, () => { + assert.equal(resolveMainServerHost(), MAIN_SERVER_DEFAULT_HOST); + }); + // run-next.mjs path: the published value wins over anything ambient. + withEnv({ ...CLEAR_HOST, OMNIROUTE_BOUND_HOST: "127.0.0.1", HOSTNAME: "build-box" }, () => { + assert.equal(resolveMainServerHost(), "127.0.0.1"); + }); + // standalone/Docker path: no published value, Next reads HOSTNAME. + withEnv({ ...CLEAR_HOST, HOSTNAME: "10.0.0.7" }, () => { + assert.equal(resolveMainServerHost(), "10.0.0.7"); + }); + // HOST is deliberately absent from the chain: the standalone server ignores + // it, and run-next.mjs has already folded it into OMNIROUTE_BOUND_HOST. + withEnv({ ...CLEAR_HOST, HOST: "10.0.0.8" }, () => { + assert.equal(resolveMainServerHost(), MAIN_SERVER_DEFAULT_HOST); + }); + // Ordering is load-bearing: on the standalone path Next binds HOSTNAME, so + // a stray HOST in the environment must not win. + withEnv({ ...CLEAR_HOST, HOSTNAME: "127.0.0.1", HOST: "192.168.1.9" }, () => { + assert.equal(resolveMainServerHost(), "127.0.0.1"); + }); +}); + +test("a loopback-bound Docker instance does not get a false warning", () => { + // The regression this ordering exists to prevent: reading only HOST would + // fall through to "0.0.0.0" and warn about an instance that is in fact + // bound to loopback. + withEnv({ ...CLEAR_HOST, REQUIRE_API_KEY: "false", HOSTNAME: "127.0.0.1" }, () => { + assert.deepEqual(captureWarn(warnIfInferenceServerExposed), []); + }); +}); + +test("the Next boot hook actually invokes the inference exposure guard", () => { + // A guard nobody calls is what #13695 is reporting: the module existed and + // was tested, but the inference server never reached it. + const boot = fs.readFileSync(path.join(REPO_ROOT, "src/instrumentation-node.ts"), "utf8"); + assert.match(boot, /warnIfInferenceServerExposed\(\)/); +}); + +test("docs state which setting gates /v1/models and which gates inference", () => { + const env = fs.readFileSync(path.join(REPO_ROOT, "docs/reference/ENVIRONMENT.md"), "utf8"); + const row = env.split("\n").find((line) => line.startsWith("| `REQUIRE_API_KEY`")); + assert.ok(row, "REQUIRE_API_KEY row missing from ENVIRONMENT.md"); + assert.match(row, /does not gate `GET \/v1\/models`/); + assert.match(row, /requireAuthForModels/); + + const doc = fs.readFileSync( + path.join(REPO_ROOT, "docs/security/INFERENCE_AUTH_POSTURE.md"), + "utf8" + ); + assert.match(doc, /requireAuthForModels/); + assert.match(doc, /REQUIRE_API_KEY/); + assert.match(doc, /does \*\*not\*\* verify that\s*(?:>\s*)?inference is protected/); + + const meta = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "docs/security/meta.json"), "utf8") + ) as { pages: string[] }; + assert.ok( + meta.pages.includes("INFERENCE_AUTH_POSTURE"), + "new security page is not registered in docs/security/meta.json" + ); +});