Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
b87c92b3d7 fix(docker): bind app/sidecar compose ports to loopback by default (#12568, #12578)
docker-compose.yml and docker-compose.prod.yml defaulted API_HOST/LIVE_WS_HOST/
HOSTNAME to 0.0.0.0 and published the dashboard/API/live-WS ports with bare,
unscoped specs, which Docker expands to every interface. Combined with
REQUIRE_API_KEY=false shipping as the .env.example default, this exposed the
anonymous /v1 LLM proxy to the whole LAN/WAN (#12568). The optional cliproxyapi
sidecar had the same unscoped publish spec plus no forwarded auth env var,
exposing a credential-bearing service the same way (#12578); qdrant and bifrost
had the identical gap.

Applies the existing Redis loopback-bind precedent (tests/unit/compose-redis-
loopback-bind.test.ts) to the app's own ports and to cliproxyapi/qdrant/bifrost:
- New APP_BIND_HOST / CLIPROXY_BIND_HOST / QDRANT_BIND_HOST / BIFROST_BIND_HOST
  opt-in vars, defaulting to 127.0.0.1, documented in .env.example and
  docs/reference/ENVIRONMENT.md.
- API_HOST/LIVE_WS_HOST default to 127.0.0.1 in both compose files; the prod
  file no longer hardcodes HOSTNAME=0.0.0.0.
- cliproxyapi now forwards CLIPROXYAPI_MANAGEMENT_KEY as MANAGEMENT_PASSWORD,
  the one env var the pinned image actually reads for its management API.
- A new boot-time guard (src/lib/startup/nonLoopbackApiKeyGuard.ts) logs a
  warning — never a hard failure — when the API bridge or live-WS server ends
  up bound to a non-loopback host while REQUIRE_API_KEY is disabled.

⚠️ base-red inherited: #12732 — unit #12058, integration codex-cache,
package-artifact, tarball-smoke, agent-skills-sync

Closes #12568
Closes #12578
2026-09-10 13:51:25 -03:00
21 changed files with 424 additions and 507 deletions

View File

@@ -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

View File

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

View File

@@ -1 +0,0 @@
- fix(api): close DNS-rebinding SSRF gap in webhook outbound-URL guard (#12569)

View File

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

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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 |

View File

@@ -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. |

View File

@@ -12,7 +12,8 @@ import { buildTelegramUrl, buildTelegramPayload } from "@/lib/webhooks/integrati
import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { insertDelivery } from "@/lib/db/webhookDeliveries";
import { fetchWebhookUrl } from "@/shared/network/webhookFetch";
import { isPrivateHost, OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import crypto from "crypto";
const MAX_RESPONSE_BODY = 2048;
@@ -30,43 +31,35 @@ async function testFetch(
}> {
const start = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
let response: Response;
let redactBody: boolean;
try {
({ response, redactBody } = await fetchWebhookUrl(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "OmniRoute-Webhook/1.0",
...headers,
},
body: JSON.stringify(body),
},
{ signal: controller.signal }
));
} finally {
clearTimeout(timeoutId);
}
const latencyMs = Date.now() - start;
const parsed = parseAndValidateWebhookUrl(url);
// For private (opted-in) targets, return connectivity diagnostics only — never the
// upstream response body, so this endpoint can't be used to exfiltrate content from
// internal services reachable from the server. (#3269 hardening) The verdict is derived
// from the DNS-resolved address, not the raw hostname string, so a public-looking hostname
// rebound to a private IP is redacted too.
// internal services reachable from the server. (#3269 hardening)
const redactBody = isPrivateHost(parsed.hostname);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "OmniRoute-Webhook/1.0",
...headers,
},
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
const latencyMs = Date.now() - start;
let rawBody = "";
try {
rawBody = await response.text();
rawBody = await res.text();
if (rawBody.length > MAX_RESPONSE_BODY) rawBody = rawBody.slice(0, MAX_RESPONSE_BODY) + "…";
} catch {
rawBody = "";
}
return {
success: response.ok,
status: response.status,
success: res.ok,
status: res.status,
latencyMs,
responseBody: redactBody ? "<redacted: private target>" : rawBody,
};

View File

@@ -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/

View File

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

View File

@@ -6,8 +6,7 @@
import crypto from "crypto";
import { encrypt, decrypt } from "./db/encryption";
import { OutboundUrlGuardError } from "@/shared/network/outboundUrlGuard";
import { fetchWebhookUrl, type WebhookFetchOptions } from "@/shared/network/webhookFetch";
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
import type { WebhookEvent } from "./webhooks/eventDescriptions";
export type { WebhookEvent };
@@ -18,10 +17,6 @@ export interface WebhookPayload {
data: Record<string, any>;
}
/** DNS-resolve/fetch overrides — production callers never pass these; tests inject a fake
* resolver and/or fetch to avoid real network access (#12569). */
export type WebhookDeliveryOptions = Pick<WebhookFetchOptions, "lookup" | "fetchImpl">;
function signPayload(payload: string, secret: string): string {
return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`;
}
@@ -43,24 +38,21 @@ export function decryptMetadata(encrypted: string | null): Record<string, string
async function deliverRaw(
url: string,
body: Record<string, unknown>,
options?: WebhookDeliveryOptions
body: Record<string, unknown>
): Promise<{ success: boolean; status: number; latencyMs: number; error?: string }> {
const start = Date.now();
try {
parseAndValidateWebhookUrl(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
try {
const { response } = await fetchWebhookUrl(
url,
{
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "OmniRoute-Webhook/1.0" },
body: JSON.stringify(body),
},
{ ...options, signal: controller.signal }
);
return { success: response.ok, status: response.status, latencyMs: Date.now() - start };
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "OmniRoute-Webhook/1.0" },
body: JSON.stringify(body),
signal: controller.signal,
});
return { success: res.ok, status: res.status, latencyMs: Date.now() - start };
} finally {
// Always clear the abort timer — on a non-timeout fetch error the previous code skipped
// clearTimeout, leaving a dangling 10s timer (and AbortController) per failed call.
@@ -80,9 +72,13 @@ export async function deliverWebhook(
url: string,
payload: WebhookPayload,
secret?: string | null,
maxRetries = 3,
options?: WebhookDeliveryOptions
maxRetries = 3
): Promise<{ success: boolean; status: number; error?: string }> {
try {
parseAndValidateWebhookUrl(url);
} catch (error: any) {
return { success: false, status: 0, error: error.message || "Blocked outbound URL" };
}
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -100,31 +96,29 @@ export async function deliverWebhook(
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
let response: Response;
let res: Response;
try {
({ response } = await fetchWebhookUrl(
url,
{ method: "POST", headers, body },
{ ...options, signal: controller.signal }
));
res = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} finally {
// Clear the abort timer on every path — a non-timeout fetch error previously skipped
// clearTimeout, leaking a dangling 10s timer + AbortController per failed attempt.
clearTimeout(timeoutId);
}
if (response.ok || response.status < 500) {
return { success: response.ok, status: response.status };
if (res.ok || res.status < 500) {
return { success: res.ok, status: res.status };
}
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
}
} catch (error: any) {
// A blocked outbound URL (private/metadata resolved address, or a redirect hop that
// resolved to one) is never transient — fail closed immediately instead of burning
// retries/backoff on something that will keep resolving the same way.
if (attempt === maxRetries || error instanceof OutboundUrlGuardError) {
if (attempt === maxRetries) {
return { success: false, status: 0, error: error.message || "Network error" };
}
await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));

View File

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

View File

@@ -1,94 +0,0 @@
import { isIP } from "node:net";
import dns from "node:dns";
import { Agent, fetch as undiciFetch } from "undici";
/**
* Shared DNS-resolve-then-pin primitives (#12569). Originally written only for
* `remoteImageFetch.ts` (GHSA-cmhj-wh2f-9cgx); extracted here so the webhook outbound-URL
* guard (`webhookFetch.ts`) can reuse the exact same connection-pinning mechanism instead of
* duplicating it. `remoteImageFetch.ts` re-exports `createPinnedFetch` from here for backward
* compatibility with its existing import path.
*/
export interface DnsLookupResult {
address: string;
family: number;
}
/**
* Minimal DNS lookup contract — matches the shape returned by
* `node:dns/promises`.lookup(host, { all: true }). Exposed as an option so
* tests can inject a fake resolver without touching real DNS.
*/
export type DnsLookup = (hostname: string) => Promise<DnsLookupResult[]>;
export const defaultDnsLookup: DnsLookup = (hostname) =>
dns.promises.lookup(hostname, { all: true });
/** Strip literal IPv6 brackets: "[::1]" -> "::1". */
export function bareHostname(hostname: string): string {
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
}
/**
* Resolve every DNS answer for a hostname, short-circuiting for an IP literal (which needs no
* lookup — it already IS the connect-time address). Fails closed: a lookup error or an empty
* answer set throws rather than being treated as "no restriction applies".
*/
export async function resolveHostnameAddresses(
hostname: string,
lookup: DnsLookup = defaultDnsLookup
): Promise<DnsLookupResult[]> {
const bare = bareHostname(hostname);
if (!bare) return [];
const literalFamily = isIP(bare);
if (literalFamily) return [{ address: bare, family: literalFamily }];
const resolved = await lookup(bare);
if (!resolved.length) {
throw new Error(`Host "${bare}" could not be resolved`);
}
return resolved;
}
/**
* Build a `fetch` bound to a single already-DNS-validated address, ignoring
* whatever the hostname resolves to at connect time. Exported for direct
* testing: this is the mechanism that closes the DNS-rebinding TOCTOU gap
* (GHSA-cmhj-wh2f-9cgx) — a second, real DNS lookup at connect time could
* otherwise return a different (possibly private) address than the one
* validated up-front.
*/
export function createPinnedFetch(address: string, family: number): typeof fetch {
const dispatcher = new Agent({
connect: {
// Node's `net.connect`/`tls.connect` invoke a custom `lookup` in one of
// two incompatible shapes depending on `options.all`: modern Node
// (autoSelectFamily / Happy Eyeballs, on by default since Node 18)
// calls `lookup(hostname, { all: true, ... }, callback)` and requires
// `callback(err, addresses[])` — an array of `{ address, family }`.
// Only when `all` is falsy does it accept the single-address form
// `callback(err, address, family)`. Handling only the single-address
// form here (as an earlier draft did) throws `ERR_INVALID_IP_ADDRESS`
// for every real request once autoSelectFamily kicks in, silently
// breaking every pinned fetch — verified by
// `tests/unit/remote-image-fetch-pin-dns-connection.test.ts`.
lookup: (_hostname, options, callback) => {
if (options && typeof options === "object" && "all" in options && options.all) {
callback(null, [{ address, family }]);
return;
}
callback(null, address, family);
},
},
});
return (async (input, init) => {
try {
return (await undiciFetch(input as string | URL, {
...(init as Parameters<typeof undiciFetch>[1]),
dispatcher,
})) as unknown as Response;
} finally {
await dispatcher.close();
}
}) as typeof fetch;
}

View File

@@ -1,5 +1,6 @@
import { isIP } from "node:net";
import dns from "node:dns";
import { Agent, fetch as undiciFetch } from "undici";
import {
type OutboundUrlGuardMode,
isPrivateHost,
@@ -8,13 +9,6 @@ import {
parseOutboundUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
// #12569: `createPinnedFetch` now lives in the shared `dnsPinnedFetch.ts` module so the
// webhook outbound-URL guard can reuse the exact same connection-pinning mechanism instead of
// duplicating it. Re-exported here for backward compatibility with existing importers of
// `@/shared/network/remoteImageFetch`.
import { createPinnedFetch } from "@/shared/network/dnsPinnedFetch";
export { createPinnedFetch };
const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;
@@ -101,6 +95,48 @@ async function assertHostnameResolvesPublic(
}
return resolved;
}
/**
* Build a `fetch` bound to a single already-DNS-validated address, ignoring
* whatever the hostname resolves to at connect time. Exported for direct
* testing: this is the mechanism that closes the DNS-rebinding TOCTOU gap
* (GHSA-cmhj-wh2f-9cgx) — a second, real DNS lookup at connect time could
* otherwise return a different (possibly private) address than the one
* `assertHostnameResolvesPublic` validated.
*/
export function createPinnedFetch(address: string, family: number): typeof fetch {
const dispatcher = new Agent({
connect: {
// Node's `net.connect`/`tls.connect` invoke a custom `lookup` in one of
// two incompatible shapes depending on `options.all`: modern Node
// (autoSelectFamily / Happy Eyeballs, on by default since Node 18)
// calls `lookup(hostname, { all: true, ... }, callback)` and requires
// `callback(err, addresses[])` — an array of `{ address, family }`.
// Only when `all` is falsy does it accept the single-address form
// `callback(err, address, family)`. Handling only the single-address
// form here (as an earlier draft did) throws `ERR_INVALID_IP_ADDRESS`
// for every real request once autoSelectFamily kicks in, silently
// breaking every pinned fetch — verified by
// `tests/unit/remote-image-fetch-pin-dns-connection.test.ts`.
lookup: (_hostname, options, callback) => {
if (options && typeof options === "object" && "all" in options && options.all) {
callback(null, [{ address, family }]);
return;
}
callback(null, address, family);
},
},
});
return (async (input, init) => {
try {
return (await undiciFetch(input as string | URL, {
...(init as Parameters<typeof undiciFetch>[1]),
dispatcher,
})) as unknown as Response;
} finally {
await dispatcher.close();
}
}) as typeof fetch;
}
function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) return timeoutSignal;

View File

@@ -1,167 +0,0 @@
import {
createPinnedFetch,
defaultDnsLookup,
resolveHostnameAddresses,
type DnsLookup,
type DnsLookupResult,
} from "@/shared/network/dnsPinnedFetch";
import {
isCloudMetadataHost,
isPrivateHost,
OutboundUrlGuardError,
parseOutboundUrl,
PROVIDER_URL_BLOCKED_MESSAGE,
} from "@/shared/network/outboundUrlGuard";
import { arePrivateProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
/**
* #12569 — DNS-resolve-then-pin fetch for webhook outbound calls (custom webhook delivery +
* the webhook test-diagnostics endpoint). `parseAndValidateWebhookUrl` in
* `outboundUrlGuardPolicy.ts` only classifies the literal hostname STRING, so a hostname an
* attacker controls (DNS pointed at 169.254.169.254 / an RFC1918 address) passed that guard
* and reached the real `fetch()` unmodified. This module resolves DNS up front, rejects any
* resolved answer that is cloud-metadata (always) or private (unless the private-provider-URL
* opt-in is on), pins the connection to a validated address, and revalidates every redirect
* hop the same way — a public host answering 302 to an internal address no longer escapes
* the guard.
*/
const DEFAULT_MAX_REDIRECTS = 3;
export interface WebhookFetchOptions {
/** DNS resolver override. Tests inject a fake resolver to avoid real network lookups. */
lookup?: DnsLookup;
/** Fetch override. Takes priority over connection pinning — the mockable escape hatch used
* by existing tests that stub `globalThis.fetch`. */
fetchImpl?: typeof fetch;
/** Pin the connection to the validated DNS answer. Default true — this is the mechanism
* that closes the DNS-rebinding TOCTOU gap. */
pinDns?: boolean;
maxRedirects?: number;
signal?: AbortSignal;
}
export interface WebhookFetchResult {
response: Response;
finalUrl: string;
/** True when a resolved hop is a private address explicitly allowed via opt-in — the
* caller must not surface the upstream response body for such a target (#3269). */
redactBody: boolean;
}
/** Reject a resolved address set that includes a metadata or (non-opted-in) private IP. */
function assertAddressesAllowed(addresses: DnsLookupResult[], url: URL): boolean {
const allowPrivate = arePrivateProviderUrlsAllowed();
let sawPrivate = false;
for (const { address } of addresses) {
if (isCloudMetadataHost(address)) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: address,
});
}
if (isPrivateHost(address)) {
if (!allowPrivate) {
throw new OutboundUrlGuardError(PROVIDER_URL_BLOCKED_MESSAGE, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: address,
});
}
sawPrivate = true;
}
}
return sawPrivate;
}
async function resolveHop(
currentUrl: string | URL,
lookup: DnsLookup
): Promise<{ url: URL; addresses: DnsLookupResult[]; redactBody: boolean }> {
const url = parseOutboundUrl(currentUrl);
let addresses: DnsLookupResult[];
try {
addresses = await resolveHostnameAddresses(url.hostname, lookup);
} catch {
throw new OutboundUrlGuardError("Webhook host could not be resolved (blocked)", {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: url.toString(),
hostname: url.hostname || null,
});
}
const redactBody = assertAddressesAllowed(addresses, url);
return { url, addresses, redactBody };
}
function pickFetchImpl(
fetchImpl: typeof fetch | undefined,
pinDns: boolean,
addresses: DnsLookupResult[]
): typeof fetch {
if (fetchImpl) return fetchImpl;
if (pinDns && addresses.length) return createPinnedFetch(addresses[0].address, addresses[0].family);
return fetch;
}
function nextRedirectUrl(
response: Response,
currentUrl: URL,
redirectCount: number,
maxRedirects: number
): URL {
const location = response.headers.get("location");
if (!location) {
throw new OutboundUrlGuardError("Webhook redirect missing Location header", {
code: "OUTBOUND_URL_INVALID",
url: currentUrl.toString(),
});
}
if (redirectCount >= maxRedirects) {
throw new OutboundUrlGuardError(`Webhook exceeded ${maxRedirects} redirect limit`, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: currentUrl.toString(),
});
}
return new URL(location, currentUrl);
}
/**
* DNS-resolve-then-pin POST/GET for a webhook URL, following redirects manually and
* revalidating DNS at every hop. Throws `OutboundUrlGuardError` when the target (or a
* redirect target) resolves to a blocked address.
*/
export async function fetchWebhookUrl(
input: string,
init: RequestInit,
options: WebhookFetchOptions = {}
): Promise<WebhookFetchResult> {
const lookup = options.lookup ?? defaultDnsLookup;
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
const pinDns = options.pinDns !== false;
let currentUrl: string | URL = input;
let redactBody = false;
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
const hop = await resolveHop(currentUrl, lookup);
redactBody = redactBody || hop.redactBody;
const fetchImpl = pickFetchImpl(options.fetchImpl, pinDns, hop.addresses);
const response = await fetchImpl(hop.url.toString(), {
...init,
redirect: "manual",
signal: options.signal,
});
if (response.status >= 300 && response.status < 400) {
currentUrl = nextRedirectUrl(response, hop.url, redirectCount, maxRedirects);
continue;
}
return { response, finalUrl: hop.url.toString(), redactBody };
}
throw new OutboundUrlGuardError(`Webhook exceeded ${maxRedirects} redirect limit`, {
code: "OUTBOUND_URL_GUARD_BLOCKED",
url: String(input),
});
}

View File

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

View File

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

View File

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

View File

@@ -8,15 +8,10 @@ const { deliverWebhook } = await import("../../src/lib/webhookDispatcher.ts");
// called clearTimeout on the success path, so a non-timeout fetch rejection
// (ECONNREFUSED, DNS failure, etc.) skipped clearTimeout, leaking a live 10s timer
// + AbortController per failed delivery. The fix clears the timer in a `finally`.
//
// #12569: deliverWebhook now DNS-resolves and pins the connection before dispatch, so a
// `globalThis.fetch` stub alone no longer intercepts the outbound call (the pinned fetch talks
// to undici directly). Inject a fake `lookup` (no real DNS) and `fetchImpl` (the documented
// escape hatch — see `WebhookDeliveryOptions`) instead, so this test stays deterministic and
// network-free while still exercising the exact "fetch rejects" path it targets.
test("deliverWebhook clears the abort timer even when fetch rejects", async () => {
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
const realFetch = globalThis.fetch;
const abortTimerIds = new Set<unknown>();
const clearedIds = new Set<unknown>();
@@ -31,20 +26,17 @@ test("deliverWebhook clears the abort timer even when fetch rejects", async () =
clearedIds.add(id);
return realClearTimeout(id);
}) as typeof clearTimeout;
// Non-timeout network failure — the exact path that previously skipped clearTimeout.
globalThis.fetch = (async () => {
throw new Error("ECONNREFUSED");
}) as typeof fetch;
try {
const res = await deliverWebhook(
"https://example.com/webhook",
{ event: "test.event" as any, timestamp: new Date().toISOString(), data: {} },
null,
0, // maxRetries=0 → single attempt, no exponential-backoff timers
{
lookup: async () => [{ address: "203.0.113.5", family: 4 }],
// Non-timeout network failure — the exact path that previously skipped clearTimeout.
fetchImpl: async () => {
throw new Error("ECONNREFUSED");
},
}
0 // maxRetries=0 → single attempt, no exponential-backoff timers
);
assert.equal(res.success, false, "delivery should fail when fetch rejects");
@@ -60,5 +52,6 @@ test("deliverWebhook clears the abort timer even when fetch rejects", async () =
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
globalThis.fetch = realFetch;
}
});

View File

@@ -1,135 +0,0 @@
/**
* Regression for issue #12569: the webhook outbound-URL guard
* (`parseAndValidateWebhookUrl`, `isPrivateHost`, `isCloudMetadataHost`) classified only the
* literal hostname STRING in the configured webhook URL. It never resolved DNS before
* deciding a target was public, so a domain an attacker controls (DNS A record pointed at
* 169.254.169.254 / an RFC1918 address) passed the guard, and the real `fetch()` that
* followed resolved DNS itself and reached the internal target (DNS rebinding).
*
* Fixed by `fetchWebhookUrl` (`src/shared/network/webhookFetch.ts`), which resolves DNS
* up-front, rejects any resolved answer that is cloud-metadata/private, and pins the
* connection to the validated address (so a *second*, real DNS lookup at connect time cannot
* rebind to a different address either).
*
* Run with:
* node --import tsx/esm --test tests/unit/webhook-dns-rebinding-ssrf-12569.test.ts
*/
import { describe, it, mock, after } from "node:test";
import assert from "node:assert/strict";
import dns from "node:dns";
import { deliverWebhook } from "../../src/lib/webhookDispatcher.ts";
const REBOUND_HOSTNAME = "evil.example.com";
const IMDS_ADDRESS = "169.254.169.254";
const originalLookup = dns.promises.lookup;
mock.method(
dns.promises,
"lookup",
async (hostname: string): Promise<dns.LookupAddress[]> => {
if (hostname === REBOUND_HOSTNAME) {
return [{ address: IMDS_ADDRESS, family: 4 }];
}
return originalLookup(hostname, { all: true });
}
);
after(() => {
mock.restoreAll();
});
describe("#12569 — webhook outbound guard is hostname-string-only (DNS rebinding)", () => {
it("does NOT let a hostname that resolves to the cloud-metadata IP reach fetch()", async () => {
const fetchCalls: string[] = [];
const originalFetch = globalThis.fetch;
// @ts-expect-error - stubbing global fetch for the probe
globalThis.fetch = async (input: string) => {
fetchCalls.push(String(input));
return new Response("ok", { status: 200 });
};
try {
const res = await deliverWebhook(
`http://${REBOUND_HOSTNAME}/hook`,
{ event: "test.ping", timestamp: new Date().toISOString(), data: {} },
"secret"
);
assert.equal(
fetchCalls.length,
0,
`guard should have blocked dispatch to a hostname resolving to ${IMDS_ADDRESS}, ` +
`but fetch() was called with: ${JSON.stringify(fetchCalls)}`
);
assert.equal(res.success, false);
} finally {
globalThis.fetch = originalFetch;
}
});
it("blocks a hostname that resolves to an RFC1918 address, without retrying", async () => {
const start = Date.now();
const res = await deliverWebhook(
"http://rebind-to-lan.example.com/hook",
{ event: "test.ping", timestamp: new Date().toISOString(), data: {} },
null,
3,
{ lookup: async () => [{ address: "10.1.2.3", family: 4 }] }
);
const elapsedMs = Date.now() - start;
assert.equal(res.success, false);
assert.ok(
typeof res.error === "string" && /private|blocked|local/i.test(res.error),
`expected guard error, got: ${res.error}`
);
// A guard-blocked verdict must fail fast — no exponential-backoff retries (1s+2s+4s) for
// something that will keep resolving the same way.
assert.ok(elapsedMs < 900, `blocked delivery must not retry with backoff (took ${elapsedMs}ms)`);
});
it("blocks when any of several resolved addresses is private (multi-A trick)", async () => {
const fetchCalls: string[] = [];
const res = await deliverWebhook(
"http://multi-answer.example.com/hook",
{ event: "test.ping", timestamp: new Date().toISOString(), data: {} },
null,
0,
{
lookup: async () => [
{ address: "203.0.113.5", family: 4 },
{ address: "169.254.169.254", family: 4 },
],
fetchImpl: async (input: string | URL) => {
fetchCalls.push(String(input));
return new Response("ok", { status: 200 });
},
}
);
assert.equal(res.success, false);
assert.equal(fetchCalls.length, 0, "fetch must never fire when any resolved IP is blocked");
});
it("allows a hostname that resolves only to public addresses", async () => {
const fetchCalls: string[] = [];
const res = await deliverWebhook(
"http://public-looking.example.com/hook",
{ event: "test.ping", timestamp: new Date().toISOString(), data: {} },
null,
0,
{
lookup: async () => [{ address: "203.0.113.5", family: 4 }],
fetchImpl: async (input: string | URL) => {
fetchCalls.push(String(input));
return new Response("ok", { status: 200 });
},
}
);
assert.equal(res.success, true);
assert.equal(fetchCalls.length, 1);
});
});