fix(docker): publish the Redis sidecar on loopback instead of 0.0.0.0 (#9286)

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
This commit is contained in:
Chedrian07
2026-08-06 09:45:56 +09:00
committed by GitHub
parent b2fc36ad08
commit fd62cb1152
10 changed files with 261 additions and 28 deletions

View File

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

View File

@@ -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 <port>", "Host port to expose", DEFAULT_PORT)
.option(
"-b, --bind <host>",
"Host interface to publish on (use 0.0.0.0 only together with --password)",
DEFAULT_BIND
)
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-i, --image <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 <secret>.`
);
}
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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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[],

View File

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

View File

@@ -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<string>(),
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<string>(),
description() { return sub; },
description() {
return sub;
},
option(flag: string) {
// Prefer the canonical long flag (`--port` from `-p, --port <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 <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 <port>: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\)/);
});

View File

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

View File

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