diff --git a/.env.example b/.env.example index 67097b7104..60fc01d619 100644 --- a/.env.example +++ b/.env.example @@ -124,6 +124,21 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Host port for the compose Redis sidecar. Default: 6379. # REDIS_PORT=6379 +# Host interface docker-compose publishes the app's own ports (dashboard, +# API, live-WS) on for the base/web/cli/host profiles and docker-compose.prod.yml. +# Default: 127.0.0.1 (loopback only). Combined with REQUIRE_API_KEY=false +# (the default below), an unqualified publish spec would expose the anonymous +# /v1 LLM proxy to your whole LAN/WAN. Only set this to 0.0.0.0 once you've +# confirmed REQUIRE_API_KEY=true, or that a reverse proxy in front of this +# instance already enforces its own authentication. (#12568) +# APP_BIND_HOST=127.0.0.1 +# Host interface docker-compose publishes the Qdrant memory sidecar on. +# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis. +# QDRANT_BIND_HOST=127.0.0.1 +# Host interface docker-compose publishes the Bifrost router sidecar on. +# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis. +# BIFROST_BIND_HOST=127.0.0.1 + # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS # ═══════════════════════════════════════════════════════════════════════════════ @@ -373,6 +388,8 @@ AUTH_COOKIE_SECURE=false # Require an API key for all /v1/* proxy endpoints. # Used by: API middleware — rejects unauthenticated requests to the proxy API. # Default: false | Set true for multi-user/public deployments. +# Leaving this false is only safe when the app is reachable on loopback only +# (see APP_BIND_HOST above) or sits behind a reverse proxy doing its own auth. REQUIRE_API_KEY=false # Allow revealing full API key values in the Dashboard UI. @@ -2077,6 +2094,13 @@ APP_LOG_TO_FILE=true # Management key for an externally managed instance. Embedded instances use # OmniRoute's encrypted service key. # CLIPROXYAPI_MANAGEMENT_KEY= +# Host interface docker-compose publishes the cliproxyapi sidecar on (the +# --profile cliproxyapi Docker service, port 8317). Default: 127.0.0.1 +# (loopback only) — its data volume holds provider OAuth/API credentials, and +# the pinned image has no env-based data-plane api-keys override (only a +# mounted config.yaml), so an unqualified publish spec would put a +# credential-bearing service on your whole LAN. (#12578) +# CLIPROXY_BIND_HOST=127.0.0.1 # ── Mux embedded service ── # Override the port where the embedded Mux (coder/mux) agent-orchestration diff --git a/changelog.d/fixes/12568-compose-loopback-bind.md b/changelog.d/fixes/12568-compose-loopback-bind.md new file mode 100644 index 0000000000..ab5c513796 --- /dev/null +++ b/changelog.d/fixes/12568-compose-loopback-bind.md @@ -0,0 +1 @@ +- fix(docker): default docker-compose app ports (dashboard/API/live-WS) to loopback instead of `0.0.0.0`, closing the anonymous `/v1` LAN/WAN exposure gap left open by `REQUIRE_API_KEY=false` (#12568) diff --git a/changelog.d/fixes/12578-cliproxyapi-loopback-bind.md b/changelog.d/fixes/12578-cliproxyapi-loopback-bind.md new file mode 100644 index 0000000000..38eec82fc8 --- /dev/null +++ b/changelog.d/fixes/12578-cliproxyapi-loopback-bind.md @@ -0,0 +1 @@ +- fix(docker): scope the cliproxyapi/qdrant/bifrost sidecars to loopback by default and forward `CLIPROXYAPI_MANAGEMENT_KEY` into the cliproxyapi container so its management API is not left both unauthenticated and LAN-published (#12578) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 547b319c50..6b815824f3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -63,17 +63,22 @@ services: - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} - API_PORT=${API_PORT:-20129} - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} - - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1} - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}} - - API_HOST=${API_HOST:-0.0.0.0} - - HOSTNAME=0.0.0.0 + - API_HOST=${API_HOST:-127.0.0.1} + # HOSTNAME intentionally not hardcoded to 0.0.0.0 (#12568) — let the + # app's own loopback-first default apply unless the operator sets it. - DATA_DIR=/app/data - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - - "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" - - "${PROD_API_PORT:-20131}:${API_PORT:-20129}" - - "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" + # Loopback-only by default (#12568) — see docker-compose.yml's + # APP_BIND_HOST comment for the rationale. Override for a LAN/WAN prod + # deployment only once REQUIRE_API_KEY=true or a reverse proxy in front + # of this instance is confirmed to enforce its own auth. + - "${APP_BIND_HOST:-127.0.0.1}:${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${APP_BIND_HOST:-127.0.0.1}:${PROD_API_PORT:-20131}:${API_PORT:-20129}" + - "${APP_BIND_HOST:-127.0.0.1}:${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - omniroute-prod-data:/app/data healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index a57e82f666..831f4ea175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,9 +37,9 @@ x-common: &common - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - - API_HOST=${API_HOST:-0.0.0.0} + - API_HOST=${API_HOST:-127.0.0.1} - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} - - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1} - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - NODE_OPTIONS=--max-old-space-size=2048 @@ -99,9 +99,14 @@ services: OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:base ports: - - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - - "${API_PORT:-20129}:${API_PORT:-20129}" - - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" + # Loopback-only by default (#12568): with REQUIRE_API_KEY=false shipping + # as the .env.example default, an unqualified publish spec here binds + # 0.0.0.0 and exposes the anonymous /v1 LLM proxy on every LAN/WAN + # interface. Set APP_BIND_HOST=0.0.0.0 only once you've confirmed + # REQUIRE_API_KEY=true or an upstream reverse proxy enforces its own auth. + - "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" + - "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}" + - "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - base @@ -126,17 +131,17 @@ services: - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - - API_HOST=${API_HOST:-0.0.0.0} + - API_HOST=${API_HOST:-127.0.0.1} - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} - - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1} - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-} - CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 ports: - - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - - "${API_PORT:-20129}:${API_PORT:-20129}" - - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" + - "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" + - "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}" + - "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" profiles: - web @@ -165,9 +170,9 @@ services: OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:cli ports: - - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - - "${API_PORT:-20129}:${API_PORT:-20129}" - - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" + - "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" + - "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}" + - "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" volumes: - ./data:/app/data # SECURITY: mounting the host Docker socket gives this container full @@ -194,17 +199,17 @@ services: OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-} image: omniroute:base ports: - - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - - "${API_PORT:-20129}:${API_PORT:-20129}" - - "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" + - "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" + - "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}" + - "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - - API_HOST=${API_HOST:-0.0.0.0} + - API_HOST=${API_HOST:-127.0.0.1} - LIVE_WS_PORT=${LIVE_WS_PORT:-20132} - - LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0} + - LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1} - LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128} - CLI_MODE=host - CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin @@ -243,8 +248,8 @@ services: container_name: omniroute-qdrant restart: unless-stopped ports: - - "${QDRANT_PORT:-6333}:6333" - - "${QDRANT_GRPC_PORT:-6334}:6334" + - "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_PORT:-6333}:6333" + - "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_GRPC_PORT:-6334}:6334" volumes: - qdrant-data:/qdrant/storage environment: @@ -271,7 +276,7 @@ services: container_name: omniroute-bifrost restart: unless-stopped ports: - - "${BIFROST_PORT:-8080}:8080" + - "${BIFROST_BIND_HOST:-127.0.0.1}:${BIFROST_PORT:-8080}:8080" volumes: - bifrost-data:/data environment: @@ -294,12 +299,22 @@ services: image: docker.io/eceasy/cli-proxy-api:v6.9.7 restart: unless-stopped ports: - - "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}" + # Loopback-only by default: this sidecar's data volume + # (cliproxiapi-data:/root/.cli-proxy-api) holds provider OAuth/API + # credentials, and the pinned image only reads api-keys from a mounted + # config.yaml (not env vars), so an unqualified "8317:8317" publish spec + # would put a credential-bearing service with no compose-configured + # data-plane auth on every LAN interface. Same reasoning as Redis above. + - "${CLIPROXY_BIND_HOST:-127.0.0.1}:${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}" volumes: - cliproxyapi-data:/root/.cli-proxy-api environment: - PORT=${CLIPROXYAPI_PORT:-8317} - HOST=0.0.0.0 + # Forwards to the one auth-related env var the pinned binary actually + # reads (MANAGEMENT_PASSWORD) — secures the management API only; the + # data-plane completions endpoints have no env-based override upstream. + - MANAGEMENT_PASSWORD=${CLIPROXYAPI_MANAGEMENT_KEY:-} healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"] diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 69a4e5a9e5..914de38d47 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -338,6 +338,8 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), | `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) | | `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above. Coding agents: `8192`+ (see [runtime RAM](#runtime-ram-for-coding-agents)). | `1024` | | `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | +| `APP_BIND_HOST` | Host interface docker-compose publishes the dashboard/API/live-WS ports on. With `REQUIRE_API_KEY=false` (the default), `0.0.0.0` exposes the anonymous `/v1` proxy to the LAN — only widen with `REQUIRE_API_KEY=true` or a reverse proxy in front. | `127.0.0.1` | +| `CLIPROXY_BIND_HOST` | Host interface docker-compose publishes the `cliproxyapi` sidecar on — its data volume holds provider credentials. | `127.0.0.1` | | `OMNIROUTE_PLUGINS_DIR` | Directory the runtime plugin scanner reads and installs into. Set it when plugins are bind-mounted: the default follows `HOME`, which an image need not export. | `~/.omniroute/plugins` | | `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ | | `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f357f73584..4602d5f293 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1072,6 +1072,7 @@ desktop install. | `CLIPROXYAPI_API_KEY` | _(empty)_ | `open-sse/handlers/chatCore/cliproxyapiCredentials.ts` | Data-plane key fallback when the `cliproxyapi_api_key` setting is absent. | | `CLIPROXYAPI_MANAGEMENT_KEY` | _(empty)_ | `src/lib/services/cliproxyAccountHealth.ts` | Management key for account-health reads from an externally managed CLIProxyAPI instance. | | `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | +| `CLIPROXY_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the `cliproxyapi` sidecar on (#12578). Its data volume holds provider OAuth/API credentials and the pinned image has no env-based data-plane `api-keys` override (only a mounted `config.yaml`), so `0.0.0.0` exposes a credential-bearing service to the whole LAN. | | `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). | | `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). | | `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. | @@ -1381,6 +1382,9 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. | | `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. | | `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. | +| `APP_BIND_HOST` | `127.0.0.1` | `docker-compose.yml`, `docker-compose.prod.yml` | Host interface docker-compose publishes the app's own dashboard/API/live-WS ports on (#12568). With `REQUIRE_API_KEY=false` shipping as the `.env.example` default, `0.0.0.0` exposes the anonymous `/v1` LLM proxy to the whole LAN/WAN — only widen once `REQUIRE_API_KEY=true` or a reverse proxy in front enforces its own auth. | +| `QDRANT_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Qdrant memory sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. | +| `BIFROST_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Bifrost router sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. | | `REDIS_KEY_PREFIX` | `omniroute:` | `src/shared/utils/rateLimiter.ts` | Namespace prefix applied to every OmniRoute Redis key (rate limiter, auth cache, quota store). Prevents key collisions when the Redis instance is shared with other apps (#11042). | | `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. | | `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. | diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index 8ce477c34a..02082cafed 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -2,6 +2,7 @@ import http from "http"; import type { IncomingMessage, ServerResponse } from "http"; import net from "net"; import { getRuntimePorts } from "@/lib/runtime/ports"; +import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard"; import { getApiBridgeTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; import { attachRequestStreamGuards, @@ -184,6 +185,7 @@ export function initApiBridgeServer(): void { if (apiPort === dashboardPort) return; const host = process.env.API_HOST || "127.0.0.1"; + warnIfNonLoopbackWithoutApiKey("API bridge", host); const server = http.createServer((req, res) => { // Absorb client-abort errors (browser closes the socket during navigation/ diff --git a/src/lib/startup/nonLoopbackApiKeyGuard.ts b/src/lib/startup/nonLoopbackApiKeyGuard.ts new file mode 100644 index 0000000000..ceaf5c3b7f --- /dev/null +++ b/src/lib/startup/nonLoopbackApiKeyGuard.ts @@ -0,0 +1,36 @@ +// Boot-time guard for issue #12568: docker-compose can be told to bind the +// dashboard/API/live-WS ports to a non-loopback interface (APP_BIND_HOST, +// API_HOST, LIVE_WS_HOST) while REQUIRE_API_KEY still defaults to `false`. +// That combination puts the anonymous /v1 LLM proxy on the LAN/WAN with no +// key required. This never hard-fails the boot (a reverse proxy in front of +// OmniRoute may already be doing its own auth) — it only logs a loud warning +// so the operator notices the exposure instead of discovering it from traffic. + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "::ffff:127.0.0.1"]); + +function isLoopbackHost(host: string): boolean { + return LOOPBACK_HOSTS.has(host.trim().toLowerCase()); +} + +function isRequireApiKeyDisabled(): boolean { + const raw = (process.env.REQUIRE_API_KEY || "").trim().toLowerCase(); + // Matches the feature-flag default: unset/empty falls back to "false". + return raw !== "true" && raw !== "1" && raw !== "yes"; +} + +/** + * Logs a warning when `host` resolves to a non-loopback interface while + * REQUIRE_API_KEY is disabled. Never throws and never blocks startup. + */ +export function warnIfNonLoopbackWithoutApiKey(serverLabel: string, host: string): void { + if (isLoopbackHost(host)) return; + if (!isRequireApiKeyDisabled()) return; + + console.warn( + `[startup] ${serverLabel} is bound to non-loopback host "${host}" while ` + + "REQUIRE_API_KEY is disabled — this exposes the anonymous /v1 proxy to " + + "every reachable network interface. Set REQUIRE_API_KEY=true, or bind " + + "back to 127.0.0.1, unless a reverse proxy in front of this instance " + + "already enforces its own authentication." + ); +} diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index a6cd0f5e5d..bd870ba604 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -32,6 +32,7 @@ import type { DashboardEventName, DashboardEventMap, DashboardChannel } from "@/ import { CHANNEL_EVENTS, getChannelForEvent } from "@/lib/events/types"; import { isAutomatedTestProcess, isBuildProcess } from "@/shared/utils/testProcess"; +import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard"; import { attachRequestStreamGuards, @@ -650,6 +651,7 @@ export function isLiveWsEnabled(): boolean { if (!isBuildOrTest() && isLiveWsEnabled()) { const port = parseInt(process.env.LIVE_WS_PORT || String(DEFAULT_PORT), 10); const host = process.env.LIVE_WS_HOST || DEFAULT_HOST; + warnIfNonLoopbackWithoutApiKey("Live dashboard WebSocket", host); startLiveDashboardServer(port, host).catch((err) => { console.error("[LiveWS] Failed to start: %s", err instanceof Error ? err.message : String(err)); }); diff --git a/tests/unit/compose-app-ports-loopback-bind.test.ts b/tests/unit/compose-app-ports-loopback-bind.test.ts new file mode 100644 index 0000000000..d85ec2fd32 --- /dev/null +++ b/tests/unit/compose-app-ports-loopback-bind.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); + +// docker-compose.yml (base/web/cli/host profiles) and docker-compose.prod.yml +// default API_HOST/LIVE_WS_HOST/HOSTNAME to 0.0.0.0 and publish the dashboard/ +// API/live-WS ports with a bare, unscoped spec — Docker expands that to every +// interface. Combined with .env.example shipping REQUIRE_API_KEY=false by +// default, this puts the anonymous /v1 LLM proxy on the LAN/WAN. Mirrors the +// existing Redis precedent (tests/unit/compose-redis-loopback-bind.test.ts). +// Issue #12568. + +function readCompose(file: string): string { + return fs.readFileSync(path.join(REPO_ROOT, file), "utf8"); +} + +test("docker-compose.yml publishes the dashboard/API/live-WS ports on loopback by default", () => { + const compose = readCompose("docker-compose.yml"); + const barePublishSpecs = [ + /- "\$\{DASHBOARD_PORT:-20128\}:\$\{DASHBOARD_PORT:-20128\}"/, + /- "\$\{API_PORT:-20129\}:\$\{API_PORT:-20129\}"/, + /- "\$\{LIVE_WS_PORT:-20132\}:\$\{LIVE_WS_PORT:-20132\}"/, + ]; + for (const re of barePublishSpecs) { + assert.doesNotMatch(compose, re, `unqualified publish spec ${re} binds 0.0.0.0`); + } + assert.match( + compose, + /- "\$\{APP_BIND_HOST:-127\.0\.0\.1\}:\$\{DASHBOARD_PORT:-20128\}:\$\{DASHBOARD_PORT:-20128\}"/ + ); + assert.doesNotMatch(compose, /API_HOST=\$\{API_HOST:-0\.0\.0\.0\}/); + assert.doesNotMatch(compose, /LIVE_WS_HOST=\$\{LIVE_WS_HOST:-0\.0\.0\.0\}/); +}); + +test("docker-compose.prod.yml publishes the app's ports on loopback by default", () => { + const compose = readCompose("docker-compose.prod.yml"); + assert.doesNotMatch(compose, /API_HOST=\$\{API_HOST:-0\.0\.0\.0\}/); + assert.doesNotMatch(compose, /LIVE_WS_HOST=\$\{LIVE_WS_HOST:-0\.0\.0\.0\}/); + assert.doesNotMatch(compose, /HOSTNAME=0\.0\.0\.0/); + assert.match(compose, /\$\{APP_BIND_HOST:-127\.0\.0\.1\}:\$\{PROD_DASHBOARD_PORT/); +}); + +test(".env.example does not ship REQUIRE_API_KEY=false without a boot-time non-loopback guard", () => { + const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"); + const requireApiKeyFalse = /^REQUIRE_API_KEY=false\s*$/m.test(env); + if (requireApiKeyFalse) { + const guardHits = ["src/server", "src/lib", "open-sse"].some((dir) => { + try { + const files = fs.readdirSync(path.join(REPO_ROOT, dir), { recursive: true }) as string[]; + return files.some((f) => { + if (!f.endsWith(".ts")) return false; + const full = path.join(REPO_ROOT, dir, f); + if (!fs.statSync(full).isFile()) return false; + const content = fs.readFileSync(full, "utf8"); + return content.includes("non-loopback") && content.includes("REQUIRE_API_KEY"); + }); + } catch { + return false; + } + }); + assert.ok(guardHits, "REQUIRE_API_KEY=false ships with no boot-time non-loopback guard"); + } +}); + +test(".env.example documents APP_BIND_HOST and its default", () => { + const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"); + assert.match(env, /# APP_BIND_HOST=127\.0\.0\.1/); +}); diff --git a/tests/unit/compose-cliproxyapi-loopback-bind.test.ts b/tests/unit/compose-cliproxyapi-loopback-bind.test.ts new file mode 100644 index 0000000000..f46c969f67 --- /dev/null +++ b/tests/unit/compose-cliproxyapi-loopback-bind.test.ts @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); + +// The optional `cliproxyapi` sidecar (profile `cliproxyapi`) proxies provider +// credentials (its data volume is `cliproxyapi-data:/root/.cli-proxy-api`) and +// carried no auth-related environment variable in its `environment:` block. +// Docker/Podman expand an unqualified "8317:8317" publish spec to 0.0.0.0, +// which puts this credential-bearing sidecar on every LAN interface the same +// way a bare "6379:6379" would for Redis (see +// tests/unit/compose-redis-loopback-bind.test.ts, the precedent this repo +// already applied). Issue #12578. + +function readCompose(file: string): string { + return fs.readFileSync(path.join(REPO_ROOT, file), "utf8"); +} + +test("docker-compose publishes cliproxyapi on loopback by default", () => { + const compose = readCompose("docker-compose.yml"); + assert.match( + compose, + /- "\$\{CLIPROXY_BIND_HOST:-127\.0\.0\.1\}:\$\{CLIPROXYAPI_PORT:-8317\}:\$\{CLIPROXYAPI_PORT:-8317\}"/, + "cliproxyapi publish spec must default to 127.0.0.1 (matching the Redis precedent)" + ); + assert.doesNotMatch( + compose, + /- "\$\{CLIPROXYAPI_PORT:-8317\}:\$\{CLIPROXYAPI_PORT:-8317\}"/, + "unqualified cliproxyapi publish spec binds 0.0.0.0" + ); +}); + +test("cliproxyapi service forwards a management/auth key into its environment", () => { + const compose = readCompose("docker-compose.yml"); + const serviceMatch = compose.match(/ {2}cliproxyapi:\n(?:.*\n)*?(?=\n {2}\S|$)/); + assert.ok(serviceMatch, "cliproxyapi service block must exist in docker-compose.yml"); + assert.match( + serviceMatch![0], + /CLIPROXYAPI_MANAGEMENT_KEY/, + "cliproxyapi environment block must forward CLIPROXYAPI_MANAGEMENT_KEY (already documented in docs/reference/ENVIRONMENT.md) instead of leaving auth entirely to the upstream image's undocumented default" + ); +}); + +test("qdrant and bifrost sidecars also publish on loopback by default", () => { + const compose = readCompose("docker-compose.yml"); + assert.match(compose, /- "\$\{QDRANT_BIND_HOST:-127\.0\.0\.1\}:\$\{QDRANT_PORT:-6333\}:6333"/); + assert.match( + compose, + /- "\$\{QDRANT_BIND_HOST:-127\.0\.0\.1\}:\$\{QDRANT_GRPC_PORT:-6334\}:6334"/ + ); + assert.match(compose, /- "\$\{BIFROST_BIND_HOST:-127\.0\.0\.1\}:\$\{BIFROST_PORT:-8080\}:8080"/); +}); + +test(".env.example documents CLIPROXY_BIND_HOST and its default", () => { + const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"); + assert.match(env, /# CLIPROXY_BIND_HOST=127\.0\.0\.1/); +}); diff --git a/tests/unit/non-loopback-api-key-guard.test.ts b/tests/unit/non-loopback-api-key-guard.test.ts new file mode 100644 index 0000000000..61b6ba0cde --- /dev/null +++ b/tests/unit/non-loopback-api-key-guard.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard"; + +// #12568: docker-compose can bind the app's ports to a non-loopback interface +// while REQUIRE_API_KEY still defaults to false, exposing the anonymous /v1 +// proxy to the LAN/WAN. This guard warns (never blocks) when that combination +// is detected at server startup. + +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("warns when bound to 0.0.0.0 with REQUIRE_API_KEY unset (default false)", () => { + withEnv({ REQUIRE_API_KEY: undefined }, () => { + const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "0.0.0.0")); + assert.equal(messages.length, 1); + assert.match(messages[0], /non-loopback host "0\.0\.0\.0"/); + assert.match(messages[0], /REQUIRE_API_KEY/); + }); +}); + +test("warns when bound to a LAN IP with REQUIRE_API_KEY=false", () => { + withEnv({ REQUIRE_API_KEY: "false" }, () => { + const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "192.168.1.5")); + assert.equal(messages.length, 1); + }); +}); + +test("stays silent when bound to loopback regardless of REQUIRE_API_KEY", () => { + withEnv({ REQUIRE_API_KEY: "false" }, () => { + const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "127.0.0.1")); + assert.equal(messages.length, 0); + }); + withEnv({ REQUIRE_API_KEY: "false" }, () => { + const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "::1")); + assert.equal(messages.length, 0); + }); +}); + +test("stays silent when bound to 0.0.0.0 with REQUIRE_API_KEY=true", () => { + withEnv({ REQUIRE_API_KEY: "true" }, () => { + const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "0.0.0.0")); + assert.equal(messages.length, 0); + }); +});