From fd62cb1152da7ed541891af1f62b05832b76cc04 Mon Sep 17 00:00:00 2001 From: Chedrian07 <108463785+Chedrian07@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:45:56 +0900 Subject: [PATCH] fix(docker): publish the Redis sidecar on loopback instead of 0.0.0.0 (#9286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log --- .env.example | 13 +++ bin/cli/commands/redis.mjs | 80 ++++++++++++++++--- changelog.d/fixes/9286-redis-loopback-bind.md | 1 + docker-compose.yml | 8 +- docs/guides/DOCKER_GUIDE.md | 25 ++++-- src/app/api/local/redis/redisRuntime.ts | 21 +++++ src/app/api/local/redis/start/route.ts | 6 +- tests/unit/cli-redis-command.test.ts | 62 ++++++++++++-- .../unit/compose-redis-loopback-bind.test.ts | 48 +++++++++++ tests/unit/local-redis-runtime.test.ts | 25 ++++++ 10 files changed, 261 insertions(+), 28 deletions(-) create mode 100644 changelog.d/fixes/9286-redis-loopback-bind.md create mode 100644 tests/unit/compose-redis-loopback-bind.test.ts diff --git a/.env.example b/.env.example index 6a33eb6d0b..aca1ba9640 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,14 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Used by: src/shared/utils/rateLimiter.ts # Example: redis://localhost:6379 (or redis://redis:6379 in Docker) # REDIS_URL=redis://localhost:6379 +# Host interface docker-compose publishes the Redis sidecar on. +# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT +# `requirepass`, and app containers reach it over the compose network +# (redis:6379) — the published port is only for host-side tooling. Setting this +# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN. +# REDIS_BIND_HOST=127.0.0.1 +# Host port for the compose Redis sidecar. Default: 6379. +# REDIS_PORT=6379 # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS @@ -2252,6 +2260,11 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Host port for the 1-click Redis launcher. Default: 6379. Bump if the host # already binds 6379. The container's internal port stays 6379. # OMNIROUTE_REDIS_HOST_PORT= +# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1 +# (loopback only). 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. +# OMNIROUTE_REDIS_BIND_HOST= # Redis image used by the 1-click Redis launcher. Default: redis:7-alpine. # Override to redis:8-alpine or a private registry mirror as needed. # OMNIROUTE_REDIS_IMAGE= diff --git a/bin/cli/commands/redis.mjs b/bin/cli/commands/redis.mjs index e841abf00b..dd593d9628 100644 --- a/bin/cli/commands/redis.mjs +++ b/bin/cli/commands/redis.mjs @@ -10,9 +10,25 @@ const DEFAULT_IMAGE = "docker.io/redis:7-alpine"; const DEFAULT_NAME = "omniroute-redis"; const DEFAULT_PORT = "6379"; const DEFAULT_VOLUME = "omniroute-redis-data"; +// The launcher starts Redis without AUTH unless --password is given, so the +// published port stays on loopback. `-p 6379:6379` would bind 0.0.0.0 and hand +// the whole LAN an unauthenticated Redis. +const DEFAULT_BIND = "127.0.0.1"; const RUNTIME_PREFERENCE = ["podman", "docker"]; +/** + * Build the `-p` publish spec for the Redis container. + * Always host-qualified so the runtime never falls back to 0.0.0.0. + */ +export function buildRedisPublishSpec(bind = DEFAULT_BIND, port = DEFAULT_PORT) { + const host = String(bind || DEFAULT_BIND).trim() || DEFAULT_BIND; + const hostPort = String(port || DEFAULT_PORT).trim() || DEFAULT_PORT; + // Bracket IPv6 literals (e.g. ::1) so `host:port:port` stays unambiguous. + const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `${normalizedHost}:${hostPort}:6379`; +} + async function detectRuntime() { for (const candidate of RUNTIME_PREFERENCE) { try { @@ -27,7 +43,14 @@ async function detectRuntime() { async function containerExists(runtime, name) { try { - const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]); + const { stdout } = await execFile(runtime, [ + "ps", + "-a", + "--filter", + `name=^${name}$`, + "--format", + "{{.Names}}", + ]); return stdout.trim() === name; } catch { return false; @@ -36,7 +59,13 @@ async function containerExists(runtime, name) { async function containerRunning(runtime, name) { try { - const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]); + const { stdout } = await execFile(runtime, [ + "ps", + "--filter", + `name=^${name}$`, + "--format", + "{{.Names}}", + ]); return stdout.trim() === name; } catch { return false; @@ -100,6 +129,11 @@ export function registerRedis(program) { .command("up") .description("Start the local Redis container") .option("-p, --port ", "Host port to expose", DEFAULT_PORT) + .option( + "-b, --bind ", + "Host interface to publish on (use 0.0.0.0 only together with --password)", + DEFAULT_BIND + ) .option("-n, --name ", "Container name", DEFAULT_NAME) .option("-i, --image ", "Container image", DEFAULT_IMAGE) .option("--no-pull", "Skip pulling the image if it is missing") @@ -160,6 +194,7 @@ export async function runRedisUpCommand(opts = {}) { const name = opts.name || DEFAULT_NAME; const port = opts.port || DEFAULT_PORT; + const bind = opts.bind || DEFAULT_BIND; const image = opts.image || DEFAULT_IMAGE; const exists = await containerExists(runtime, name); @@ -186,7 +221,11 @@ export async function runRedisUpCommand(opts = {}) { info(`Checking if image '${image}' is present locally…`); let present = false; try { - const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]); + const { stdout } = await execFile(runtime, [ + "images", + "--format", + "{{.Repository}}:{{.Tag}}", + ]); present = stdout.split("\n").some((line) => line.trim() === image); } catch { // ignore — fall through to pull @@ -205,10 +244,14 @@ export async function runRedisUpCommand(opts = {}) { const args = [ "run", "-d", - "--name", name, - "--restart", "unless-stopped", - "-p", `${port}:6379`, - "-v", `${DEFAULT_VOLUME}:/data`, + "--name", + name, + "--restart", + "unless-stopped", + "-p", + buildRedisPublishSpec(bind, port), + "-v", + `${DEFAULT_VOLUME}:/data`, ]; if (opts.password) { args.push("-e", `REDIS_PASSWORD=${opts.password}`); @@ -219,8 +262,13 @@ export async function runRedisUpCommand(opts = {}) { info(`Launching ${runtime} run ${args.join(" ")}`); try { await execFile(runtime, args); - success(`Container '${name}' is now running on redis://127.0.0.1:${port}`); - info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`); + success(`Container '${name}' is now running on redis://${bind}:${port}`); + info(`Set OMNIROUTE_REDIS_URL=redis://${bind}:${port} in your .env to wire OmniRoute to it.`); + if (bind !== DEFAULT_BIND && !opts.password) { + info( + `Warning: '${bind}' publishes Redis beyond loopback without AUTH. Re-run with --password .` + ); + } return 0; } catch (err) { fail(`Failed to launch container: ${err.message}`); @@ -267,7 +315,13 @@ export async function runRedisStatusCommand(opts = {}) { const exists = await containerExists(runtime, name); if (!exists) { - console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2)); + console.log( + JSON.stringify( + { runtime, name, port, exists: false, running: false, reachable: false }, + null, + 2 + ) + ); return 0; } @@ -285,10 +339,12 @@ export async function runRedisStatusCommand(opts = {}) { console.log(` Running: ${running ? "yes" : "no"}`); console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`); if (running && !reachable) { - warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"); + warn( + "Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?" + ); } if (!running) { info(`Run 'omniroute redis up' to launch it.`); } return 0; -} \ No newline at end of file +} diff --git a/changelog.d/fixes/9286-redis-loopback-bind.md b/changelog.d/fixes/9286-redis-loopback-bind.md new file mode 100644 index 0000000000..dbd6448bf5 --- /dev/null +++ b/changelog.d/fixes/9286-redis-loopback-bind.md @@ -0,0 +1 @@ +- **fix(docker):** the bundled Redis sidecar no longer publishes on `0.0.0.0`. `docker-compose.yml`, `omniroute redis up` and the dashboard's 1-click launcher all built an unqualified `-p :6379` spec, which the container runtime expands to every interface — and none of them sets `requirepass`, so any host on the LAN could reach the rate-limiter/cache store. All three now default to `127.0.0.1`, with exposure opt-in via `REDIS_BIND_HOST` (compose), `--bind` (CLI) and `OMNIROUTE_REDIS_BIND_HOST` (launcher); the CLI warns when a non-loopback bind is requested without `--password` ([#9286](https://github.com/diegosouzapw/OmniRoute/pull/9286)) diff --git a/docker-compose.yml b/docker-compose.yml index b20b067788..427b468ccd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,13 @@ services: container_name: omniroute-redis restart: unless-stopped ports: - - "${REDIS_PORT:-6379}:6379" + # Loopback-only by default: this Redis has no `requirepass`, and the app + # containers reach it over the compose network (redis:6379), so the + # published port exists purely for host-side tooling (redis-cli, a local + # `npm run dev`). A bare "6379:6379" binds 0.0.0.0 — that puts an + # unauthenticated Redis on every LAN interface. Override REDIS_BIND_HOST + # only together with a password (`--requirepass`). + - "${REDIS_BIND_HOST:-127.0.0.1}:${REDIS_PORT:-6379}:6379" volumes: - redis-data:/data command: redis-server --save 60 1 --loglevel warning diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 242c215ae6..5233889912 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -86,19 +86,27 @@ OmniRoute ships four Compose profiles. Pick the one that matches your environmen OmniRoute relies on Redis to back the distributed rate limiter and shared cache. The `redis` service is **always defined** in `docker-compose.yml` (it has no profile gate) and starts alongside any other profile. -| Detail | Value | -| -------------------- | --------------------------------- | -| Image | `redis:7-alpine` | -| Container name | `omniroute-redis` | -| Internal port | `6379` | -| Host port (override) | `REDIS_PORT` (defaults to `6379`) | -| Volume | `omniroute-redis-data` → `/data` | -| Healthcheck | `redis-cli ping` (10s interval) | +| Detail | Value | +| -------------------- | ------------------------------------------- | +| Image | `redis:7-alpine` | +| Container name | `omniroute-redis` | +| Internal port | `6379` | +| Host port (override) | `REDIS_PORT` (defaults to `6379`) | +| Host bind (override) | `REDIS_BIND_HOST` (defaults to `127.0.0.1`) | +| Volume | `omniroute-redis-data` → `/data` | +| Healthcheck | `redis-cli ping` (10s interval) | Related environment variables: - `REDIS_URL` — connection string injected into the app (`redis://redis:6379` by default). - `REDIS_PORT` — host-side port mapping for the Redis container. +- `REDIS_BIND_HOST` — host interface the port is published on. Defaults to `127.0.0.1`. + +> **Why loopback by default:** the sidecar runs without `requirepass`, and the app +> containers reach it over the compose network (`redis:6379`) — the published port is +> only there for host-side tooling (`redis-cli`, a local `npm run dev`). Publishing on +> `0.0.0.0` would expose an unauthenticated Redis to every host on your LAN. If you set +> `REDIS_BIND_HOST=0.0.0.0`, add `--requirepass` to the service `command:` as well. **Disabling Redis** is not recommended (rate limiter will degrade to in-memory fallback). If you must, either remove/comment the `redis:` service block in `docker-compose.yml` or scale it to zero: @@ -170,6 +178,7 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), | `OMNIROUTE_WS_BRIDGE_SECRET` | Shared secret for the WebSocket bridge. **Required in production** — set to a strong random string. | unset (must be provided) | | `REDIS_URL` | Connection string for the rate limiter / cache backend | `redis://redis:6379` | | `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` | +| `REDIS_BIND_HOST` | Host interface the bundled Redis port is published on (loopback unless you add AUTH) | `127.0.0.1` | | `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 fallback above | `512` | | `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | diff --git a/src/app/api/local/redis/redisRuntime.ts b/src/app/api/local/redis/redisRuntime.ts index 5417b3db8f..c8f5b0da21 100644 --- a/src/app/api/local/redis/redisRuntime.ts +++ b/src/app/api/local/redis/redisRuntime.ts @@ -6,6 +6,27 @@ export const REDIS_CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME | export const RUNTIME_PREFERENCE = ["podman", "docker"] as const; +// The 1-click launcher starts Redis without AUTH, so its published port stays +// on loopback. `-p 6379:6379` would bind 0.0.0.0 and expose an unauthenticated +// Redis to the whole LAN. Operators who really need remote access set +// OMNIROUTE_REDIS_BIND_HOST and secure the instance themselves. +export const REDIS_DEFAULT_BIND_HOST = "127.0.0.1"; + +/** + * Build the `-p` publish spec for the launcher's Redis container. + * Always host-qualified so the container runtime never falls back to 0.0.0.0. + */ +export function buildRedisPublishSpec( + bindHost: string = REDIS_DEFAULT_BIND_HOST, + hostPort: string | number = "6379" +): string { + const host = String(bindHost || REDIS_DEFAULT_BIND_HOST).trim() || REDIS_DEFAULT_BIND_HOST; + const port = String(hostPort || "6379").trim() || "6379"; + // Bracket IPv6 literals (e.g. ::1) so `host:port:port` stays unambiguous. + const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `${normalizedHost}:${port}:6379`; +} + type ExecFileAsync = ( file: string, args: readonly string[], diff --git a/src/app/api/local/redis/start/route.ts b/src/app/api/local/redis/start/route.ts index 671d255f8d..df265b435c 100644 --- a/src/app/api/local/redis/start/route.ts +++ b/src/app/api/local/redis/start/route.ts @@ -5,12 +5,15 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { REDIS_CONTAINER_NAME, + REDIS_DEFAULT_BIND_HOST, + buildRedisPublishSpec, detectRedisContainerRuntime, redisRuntimeUnavailableResponse, runRedisRuntimeCommand, } from "../redisRuntime"; const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379"; +const BIND_HOST = process.env.OMNIROUTE_REDIS_BIND_HOST || REDIS_DEFAULT_BIND_HOST; const IMAGE = process.env.OMNIROUTE_REDIS_IMAGE || "docker.io/redis:7-alpine"; export async function POST() { @@ -35,7 +38,7 @@ export async function POST() { "--name", REDIS_CONTAINER_NAME, "-p", - `${HOST_PORT}:6379`, + buildRedisPublishSpec(BIND_HOST, HOST_PORT), "--restart", "unless-stopped", IMAGE, @@ -46,6 +49,7 @@ export async function POST() { runtime, name: REDIS_CONTAINER_NAME, port: HOST_PORT, + bindHost: BIND_HOST, stdout, stderr, }); diff --git a/tests/unit/cli-redis-command.test.ts b/tests/unit/cli-redis-command.test.ts index 63aae24095..b3acac6654 100644 --- a/tests/unit/cli-redis-command.test.ts +++ b/tests/unit/cli-redis-command.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; // ─── T-12 (#3932 PR-3): `omniroute redis` CLI command ───────────────────── @@ -53,18 +54,24 @@ test("registerRedis: attaches a `redis` command with up/down/status subcommands" const sub = { name: subName, options: new Set(), - description() { return sub; }, + description() { + return sub; + }, option(flag) { const optName = flag.split(/[ ,]/)[0].replace(/^-+/, ""); sub.options.add(optName); return sub; }, - action() { return sub; }, + action() { + return sub; + }, }; subStubs.push(sub); return sub; }, - description() { return redisCmd; }, + description() { + return redisCmd; + }, option(flag) { const optName = flag.split(/[ ,]/)[0].replace(/^-+/, ""); redisCmd.options.add(optName); @@ -97,7 +104,9 @@ test("registerRedis: `up` subcommand has the expected option flags", async () => const sub = { name: subName, options: new Set(), - description() { return sub; }, + description() { + return sub; + }, option(flag: string) { // Prefer the canonical long flag (`--port` from `-p, --port `); // fall back to the first token for short-only / `--no-x` flags. @@ -106,12 +115,16 @@ test("registerRedis: `up` subcommand has the expected option flags", async () => sub.options.add(optName); return sub; }, - action() { return sub; }, + action() { + return sub; + }, }; subStubs.push(sub); return sub; }, - description() { return redisCmd; }, + description() { + return redisCmd; + }, option(flag: string) { // Prefer the canonical long flag (`--port` from `-p, --port `); // fall back to the first token for short-only / `--no-x` flags. @@ -127,6 +140,7 @@ test("registerRedis: `up` subcommand has the expected option flags", async () => registerRedis(program); const upCmd = subStubs.find((s) => s.name === "up")!; assert.ok(upCmd.options.has("port"), "missing --port"); + assert.ok(upCmd.options.has("bind"), "missing --bind"); assert.ok(upCmd.options.has("name"), "missing --name"); assert.ok(upCmd.options.has("image"), "missing --image"); assert.ok(upCmd.options.has("runtime"), "missing --runtime"); @@ -182,3 +196,39 @@ test("runRedisDownCommand: returns 1 when no podman/docker is available", async process.stderr.write = origStderr; } }); + +// ─── `omniroute redis up` must publish on loopback, not 0.0.0.0 ─────────── +// The launcher starts Redis with no `requirepass` unless --password is given, +// so a bare "6379:6379" publish spec would hand the LAN an unauthenticated +// Redis (the container runtime defaults an unqualified spec to 0.0.0.0). + +test("buildRedisPublishSpec: defaults to loopback, never 0.0.0.0", async () => { + const { buildRedisPublishSpec } = await import( + `../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}` + ); + assert.equal(buildRedisPublishSpec(), "127.0.0.1:6379:6379"); + assert.equal(buildRedisPublishSpec(undefined, "6380"), "127.0.0.1:6380:6379"); + assert.equal(buildRedisPublishSpec("", ""), "127.0.0.1:6379:6379"); +}); + +test("buildRedisPublishSpec: honours an explicit bind override and brackets IPv6", async () => { + const { buildRedisPublishSpec } = await import( + `../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}` + ); + // Opt-in exposure stays possible — it just can never be the default. + assert.equal(buildRedisPublishSpec("0.0.0.0", "6379"), "0.0.0.0:6379:6379"); + assert.equal(buildRedisPublishSpec("::1", "6379"), "[::1]:6379:6379"); +}); + +test("`redis up` never builds an unqualified `-p :6379` publish arg", async () => { + const source = await readFile( + new URL("../../bin/cli/commands/redis.mjs", import.meta.url), + "utf8" + ); + assert.doesNotMatch( + source, + /"-p",\s*`\$\{port\}:6379`/, + "publish spec must be host-qualified via buildRedisPublishSpec()" + ); + assert.match(source, /"-p",\s*buildRedisPublishSpec\(bind, port\)/); +}); diff --git a/tests/unit/compose-redis-loopback-bind.test.ts b/tests/unit/compose-redis-loopback-bind.test.ts new file mode 100644 index 0000000000..c2ffdfb88c --- /dev/null +++ b/tests/unit/compose-redis-loopback-bind.test.ts @@ -0,0 +1,48 @@ +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 compose Redis runs without `requirepass`, and the app containers reach it +// over the compose network (redis:6379). The published port exists only for +// host-side tooling, so it must stay on loopback: Docker/Podman expand a bare +// "6379:6379" to 0.0.0.0, which puts an unauthenticated Redis on every LAN +// interface (observed as "Possible SECURITY ATTACK detected" in redis logs when +// anything on the network speaks HTTP at it). + +function readCompose(file: string): string { + return fs.readFileSync(path.join(REPO_ROOT, file), "utf8"); +} + +test("docker-compose publishes Redis on loopback by default", () => { + const compose = readCompose("docker-compose.yml"); + assert.match( + compose, + /- "\$\{REDIS_BIND_HOST:-127\.0\.0\.1\}:\$\{REDIS_PORT:-6379\}:6379"/, + "redis publish spec must default to 127.0.0.1" + ); + assert.doesNotMatch( + compose, + /- "\$\{REDIS_PORT:-6379\}:6379"/, + "unqualified redis publish spec binds 0.0.0.0" + ); +}); + +test("no compose file publishes a port on 0.0.0.0 implicitly for Redis", () => { + for (const file of ["docker-compose.yml", "docker-compose.prod.yml"]) { + const compose = readCompose(file); + assert.doesNotMatch( + compose, + /^\s*- "0\.0\.0\.0:\d+:6379"/m, + `${file} must not hard-code an all-interfaces Redis publish spec` + ); + } +}); + +test(".env.example documents REDIS_BIND_HOST and its default", () => { + const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"); + assert.match(env, /# REDIS_BIND_HOST=127\.0\.0\.1/); + assert.match(env, /# OMNIROUTE_REDIS_BIND_HOST=/); +}); diff --git a/tests/unit/local-redis-runtime.test.ts b/tests/unit/local-redis-runtime.test.ts index 7898495a79..16c6aee889 100644 --- a/tests/unit/local-redis-runtime.test.ts +++ b/tests/unit/local-redis-runtime.test.ts @@ -3,6 +3,8 @@ import { test } from "node:test"; import { REDIS_CONTAINER_NAME, + REDIS_DEFAULT_BIND_HOST, + buildRedisPublishSpec, detectRedisContainerRuntime, redisRuntimeUnavailableResponse, runRedisRuntimeCommand, @@ -44,6 +46,29 @@ test("runRedisRuntimeCommand trims command output", async () => { assert.deepEqual(result, { stdout: "stopped", stderr: "warning" }); }); +// ─── Redis launcher must not publish on 0.0.0.0 ────────────────────────── +// The launcher starts Redis with no `requirepass`; a bare "6379:6379" publish +// spec binds every interface and hands the LAN an unauthenticated Redis. + +test("buildRedisPublishSpec defaults to loopback, never 0.0.0.0", () => { + assert.equal(REDIS_DEFAULT_BIND_HOST, "127.0.0.1"); + assert.equal(buildRedisPublishSpec(), "127.0.0.1:6379:6379"); + assert.equal(buildRedisPublishSpec(undefined, "6380"), "127.0.0.1:6380:6379"); +}); + +test("buildRedisPublishSpec falls back to loopback for empty/blank bind hosts", () => { + assert.equal(buildRedisPublishSpec("", "6379"), "127.0.0.1:6379:6379"); + assert.equal(buildRedisPublishSpec(" ", "6379"), "127.0.0.1:6379:6379"); + assert.equal(buildRedisPublishSpec("127.0.0.1", ""), "127.0.0.1:6379:6379"); +}); + +test("buildRedisPublishSpec honours an explicit override and brackets IPv6", () => { + // Opt-in exposure is still possible — it just can never be the default. + assert.equal(buildRedisPublishSpec("0.0.0.0", "6379"), "0.0.0.0:6379:6379"); + assert.equal(buildRedisPublishSpec("::1", "6379"), "[::1]:6379:6379"); + assert.equal(buildRedisPublishSpec("[::1]", "6379"), "[::1]:6379:6379"); +}); + test("redisRuntimeUnavailableResponse preserves the route error shape", async () => { const response = redisRuntimeUnavailableResponse();