mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
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:
13
.env.example
13
.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=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/fixes/9286-redis-loopback-bind.md
Normal file
1
changelog.d/fixes/9286-redis-loopback-bind.md
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
48
tests/unit/compose-redis-loopback-bind.test.ts
Normal file
48
tests/unit/compose-redis-loopback-bind.test.ts
Normal 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=/);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user