mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
feat(security): warn at boot when the inference server is exposed anonymously (#13820)
* 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
This commit is contained in:
@@ -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))
|
||||
@@ -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 `<DATA_DIR>/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. |
|
||||
|
||||
67
docs/security/INFERENCE_AUTH_POSTURE.md
Normal file
67
docs/security/INFERENCE_AUTH_POSTURE.md
Normal file
@@ -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://<host>:<api-port>/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"<any-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
|
||||
@@ -3,6 +3,7 @@
|
||||
"pages": [
|
||||
"GUARDRAILS",
|
||||
"ERROR_SANITIZATION",
|
||||
"INFERENCE_AUTH_POSTURE",
|
||||
"ROUTE_GUARD_TIERS",
|
||||
"BAN_DETECTION",
|
||||
"AGENTROUTER_WAF",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -314,7 +314,7 @@ export async function registerQuotaFetchers(): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
177
tests/unit/inference-auth-posture-13695.test.ts
Normal file
177
tests/unit/inference-auth-posture-13695.test.ts
Normal file
@@ -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<string, string | undefined>;
|
||||
|
||||
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
||||
const prev: Record<string, string | undefined> = {};
|
||||
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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user