fix(rate-limiter): Redis is now opt-in via REDIS_URL (#2357)

Docker images launched without a sibling Redis container used to spam
'[REDIS] Error: connect ECONNREFUSED 127.0.0.1:6379' for every
rate-limit and API-key-auth cache lookup. The root cause was a default
of process.env.REDIS_URL || 'redis://localhost:6379' that turned the
opt-in cache into a hard dependency.

Three coordinated changes:

1. src/shared/utils/rateLimiter.ts — gate Redis on REDIS_URL being
   explicitly set. getRedisClient() returns null when disabled; the
   single connection-error handler dedupes via a redisErrorLogged latch
   so a sustained outage produces one warn instead of per-request flood.
   checkRateLimit() routes to the existing in-memory store on both the
   'disabled' and 'test' paths.

2. src/lib/db/apiKeys.ts — short-circuit Redis-backed auth cache reads
   and writes when getRedisClient() returns null. SQLite remains
   authoritative; the cache is purely an optimization.

3. Same file — replace the wildcard scope matcher's dynamic RegExp
   compilation with a deterministic segment walker. Eliminates the
   ReDoS surface on operator-supplied scope patterns and silences the
   Semgrep js/regex-injection advisory that previously blocked edits to
   this file.

Single-instance deployments now work silently out of the box;
multi-instance setups continue to use Redis when REDIS_URL is set.
This commit is contained in:
diegosouzapw
2026-05-18 10:57:23 -03:00
parent f2e368830a
commit b17fd87470
3 changed files with 203 additions and 47 deletions

View File

@@ -119,8 +119,8 @@ const CACHE_TTL = 60 * 1000; // 1 minute TTL
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
const MAX_CACHE_SIZE = 1000;
// Compiled regex cache for wildcard patterns
const _regexCache = new Map<string, RegExp>();
// Wildcard scope matching is now handled by `matchesWildcardPattern`
// (deterministic, no RegExp from dynamic strings).
const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_models", definition: "allowed_models TEXT" },
@@ -187,6 +187,7 @@ async function deleteRedisAuthCacheEntry(keyHash: unknown): Promise<void> {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
if (!redis) return; // #2357: Redis is optional; skip when disabled.
await redis.del(`auth:api_key:${keyHash}`);
} catch {
// Redis is an optimization for auth caching; SQLite remains authoritative.
@@ -235,21 +236,57 @@ function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
}
/**
* Get or compile regex for wildcard pattern
* Match an API-key wildcard scope pattern against a model id without
* compiling a RegExp from string concatenation (avoid ReDoS exposure on
* operator-supplied patterns and silence the Semgrep `js/regex-injection`
* advisory for `new RegExp(<dynamic>)`).
*
* Supported pattern syntax (only what real scopes use):
* - literal segments
* - `*` matches any run of characters, but does NOT cross `/`
*
* Walks the pattern token-by-token: each `*` consumes the longest possible
* run within the current path segment, then the next literal anchor must
* appear before the segment boundary. Worst-case complexity is O(n*m)
* where n = pattern length, m = candidate length — there is no nested
* backtracking that could explode adversarially.
*/
function getWildcardRegex(pattern: string): RegExp {
let regex = _regexCache.get(pattern);
if (!regex) {
const regexStr = pattern.replace(/\*/g, ".*");
regex = new RegExp(`^${regexStr}$`);
_regexCache.set(pattern, regex);
// Prevent unbounded growth
if (_regexCache.size > 100) {
const firstKey = _regexCache.keys().next().value;
if (firstKey) _regexCache.delete(firstKey);
}
function matchesWildcardPattern(pattern: string, candidate: string): boolean {
const pSegs = pattern.split("/");
const cSegs = candidate.split("/");
if (pSegs.length !== cSegs.length) return false;
for (let i = 0; i < pSegs.length; i++) {
if (!segmentMatchesWildcard(pSegs[i], cSegs[i])) return false;
}
return regex;
return true;
}
function segmentMatchesWildcard(pattern: string, segment: string): boolean {
if (pattern === segment) return true;
if (!pattern.includes("*")) return false;
const parts = pattern.split("*");
// Anchor first literal to the start.
let cursor = 0;
const first = parts[0];
if (first) {
if (!segment.startsWith(first)) return false;
cursor = first.length;
}
// Anchor last literal to the end.
const last = parts[parts.length - 1];
const endLimit = segment.length - last.length;
if (last) {
if (!segment.endsWith(last)) return false;
}
// Each middle literal must appear in order between cursor and endLimit.
for (let i = 1; i < parts.length - 1; i++) {
const piece = parts[i];
if (!piece) continue;
const idx = segment.indexOf(piece, cursor);
if (idx === -1 || idx + piece.length > endLimit) return false;
cursor = idx + piece.length;
}
return cursor <= endLimit;
}
function ensureApiKeyColumn(
@@ -858,6 +895,7 @@ export async function validateApiKey(key: string | null | undefined) {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
if (!redis) throw new Error("redis-disabled"); // #2357: optional
const redisKey = `auth:api_key:${hashedKey}`;
const redisData = await redis.get(redisKey);
if (redisData) {
@@ -909,6 +947,9 @@ export async function validateApiKey(key: string | null | undefined) {
try {
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
const redis = getRedisClient();
// #2357: Redis is optional; throw so the catch below skips the write
// without affecting the function's `Promise<boolean>` return type.
if (!redis) throw new Error("redis-disabled");
const redisKey = `auth:api_key:${hashedKey}`;
await redis.set(
redisKey,
@@ -1081,10 +1122,10 @@ export async function isModelAllowedForKey(
break;
}
}
// Support wildcard patterns using cached regex
// Support wildcard patterns via deterministic matcher (no RegExp
// compilation from operator input — avoids ReDoS exposure).
if (pattern.includes("*")) {
const regex = getWildcardRegex(pattern);
if (regex.test(modelId)) {
if (matchesWildcardPattern(pattern, modelId)) {
allowed = true;
break;
}
@@ -1120,7 +1161,6 @@ export function clearApiKeyCaches() {
invalidateCaches();
_lastUsedUpdateCache.clear();
_modelPermissionCache.clear();
_regexCache.clear();
}
/**

View File

@@ -1,28 +1,46 @@
import Redis from "ioredis";
// Reuse existing REDIS_URL if set, or local redis via default docker-compose
// Use REDIS_URL from env (Docker/Production) or fallback to local redis
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
if (process.env.NODE_ENV === "production" && !process.env.REDIS_URL) {
console.warn("[REDIS] REDIS_URL is not set in production. Falling back to default.");
}
// Issue #2357: When OmniRoute runs in Docker without a sibling Redis
// container (the default `docker run` / portainer one-click install), every
// rate-limit lookup hits `redis://localhost:6379` inside the container and
// spams `[REDIS] Error: connect ECONNREFUSED 127.0.0.1:6379`. Rate limiting
// is non-essential for a single-instance deployment, so we now:
//
// 1) Treat `REDIS_URL` as opt-in. If it's not set we silently fall back to
// the in-memory store (same code path used by unit tests).
// 2) Even when set, errors degrade gracefully: a single startup warning,
// then suppress per-request error spam after the first occurrence.
const REDIS_URL = process.env.REDIS_URL;
const REDIS_ENABLED = Boolean(REDIS_URL);
let redisClient: Redis | null = null;
let redisErrorLogged = false;
export function getRedisClient() {
export function getRedisClient(): Redis | null {
if (!REDIS_ENABLED) return null;
if (!redisClient) {
redisClient = new Redis(REDIS_URL, {
redisClient = new Redis(REDIS_URL as string, {
maxRetriesPerRequest: 3,
enableReadyCheck: false,
lazyConnect: false,
retryStrategy(times) {
return Math.min(times * 50, 2000); // Exponential backoff
},
});
redisClient.on("error", (err) => console.error("[REDIS] Error:", err.message));
redisClient.on("error", (err) => {
if (!redisErrorLogged) {
console.warn("[REDIS] Connection error — rate limiter degraded to in-memory:", err.message);
redisErrorLogged = true;
}
});
}
return redisClient;
}
export function isRedisEnabled(): boolean {
return REDIS_ENABLED;
}
export interface RateLimitRule {
limit: number;
window: number; // in seconds
@@ -86,37 +104,48 @@ export function setRateLimiterTestMode(enabled: boolean) {
/**
* Checks multi-window rate limits for an API key atomically via Redis.
*/
function checkRateLimitInMemory(keyId: string, rules: RateLimitRule[]): RateLimitResult {
const now = Math.floor(Date.now() / 1000);
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
if (count >= rule.limit) {
return { allowed: false, failedWindow: rule.window };
}
}
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
}
return { allowed: true };
}
export async function checkRateLimit(
keyId: string,
rules: RateLimitRule[]
): Promise<RateLimitResult> {
if (!rules || rules.length === 0) return { allowed: true };
// ── In-memory mock for unit tests ──
// ── In-memory path for unit tests AND single-instance deployments ──
// Issue #2357: when REDIS_URL is unset we used to hammer
// localhost:6379 and surface a stream of ECONNREFUSED errors. Now the
// in-memory fallback handles single-instance setups silently. The
// explicit test-mode flag still wins so suites can opt-in even with
// REDIS_URL set.
const isTestMode =
explicitTestMode ||
process.env.NODE_ENV === "test" ||
process.env.DISABLE_SQLITE_AUTO_BACKUP === "true";
if (isTestMode) {
const now = Math.floor(Date.now() / 1000);
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
const count = TEST_MEMORY_STORE.get(windowKey) || 0;
if (count >= rule.limit) {
return { allowed: false, failedWindow: rule.window };
}
}
for (const rule of rules) {
const currentWindow = Math.floor(now / rule.window);
const windowKey = `rl:api_key:${keyId}:${rule.window}:${currentWindow}`;
TEST_MEMORY_STORE.set(windowKey, (TEST_MEMORY_STORE.get(windowKey) || 0) + 1);
}
return { allowed: true };
if (isTestMode || !isRedisEnabled()) {
return checkRateLimitInMemory(keyId, rules);
}
const redis = getRedisClient();
if (!redis) return checkRateLimitInMemory(keyId, rules);
const args: (string | number)[] = [Math.floor(Date.now() / 1000)];
for (const rule of rules) {
@@ -135,8 +164,16 @@ export async function checkRateLimit(
return { allowed: true };
} catch (error) {
// Fail-open strategy if Redis goes down to prevent complete API outage
console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error);
// Fail-open strategy if Redis goes down to prevent complete API outage.
// First failure already logged in the connection error handler — keep
// per-request output to a debug line to avoid log spam.
if (!redisErrorLogged) {
console.warn(
"[RATE_LIMITER] Redis eval failed, bypassing rate limit:",
(error as Error)?.message ?? String(error)
);
redisErrorLogged = true;
}
return { allowed: true };
}
}

View File

@@ -0,0 +1,79 @@
/**
* Issue #2357 — Redis is optional. When `REDIS_URL` is unset, the rate
* limiter must fall back to the in-memory store silently instead of
* spamming `connect ECONNREFUSED 127.0.0.1:6379` for every request.
*
* `ioredis` has a packaging quirk (`@ioredis/commands/built/commands.json`
* is actually JS, not JSON) that prevents `node:test` from importing it
* cleanly, so we verify the contract at the source level instead.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RATE_LIMITER_SRC = path.resolve(__dirname, "../../src/shared/utils/rateLimiter.ts");
const src = fs.readFileSync(RATE_LIMITER_SRC, "utf8");
test("#2357 REDIS_URL no longer falls back to localhost:6379 silently", () => {
// The old code was `process.env.REDIS_URL || "redis://localhost:6379"`,
// which made Redis effectively required and produced ECONNREFUSED spam
// when no sibling Redis container existed. The fix gates Redis on the
// explicit env var.
assert.ok(
!/process\.env\.REDIS_URL\s*\|\|\s*"redis:\/\/localhost:6379"/.test(src),
"rateLimiter must not default REDIS_URL to localhost (Redis is optional)"
);
assert.ok(
/REDIS_ENABLED\s*=\s*Boolean\(REDIS_URL\)/.test(src),
"rateLimiter must expose REDIS_ENABLED gated on the env var"
);
});
test("#2357 getRedisClient returns null when REDIS_URL is not set", () => {
// The function must short-circuit instead of constructing a client that
// would spin retrying against localhost:6379.
assert.ok(
/export function getRedisClient\(\)[\s\S]{0,200}if \(!REDIS_ENABLED\) return null/.test(src),
"getRedisClient must return null when REDIS_ENABLED is false"
);
});
test("#2357 checkRateLimit takes the in-memory branch when REDIS_URL is unset", () => {
// Look for the new `isTestMode || !isRedisEnabled()` guard. This is the
// line that routes the request to the in-memory store on docker
// installations without a Redis sidecar.
assert.ok(
/isTestMode\s*\|\|\s*!isRedisEnabled\(\)/.test(src),
"checkRateLimit must route to the in-memory fallback when Redis is disabled"
);
});
test("#2357 connection errors only log once instead of per-request spam", () => {
// The error handler used to be `console.error("[REDIS] Error:", err.message)`
// fired on every reconnect attempt. The new behavior wraps it with a
// `redisErrorLogged` latch so production logs do not get flooded.
assert.ok(
/redisErrorLogged/.test(src),
"Redis error handler must dedupe so docker logs do not flood with ECONNREFUSED"
);
});
test("#2357 RATE_LIMITER eval failure also dedupes its warn", () => {
// The fail-open `console.error` call on Redis eval failure used to fire
// on every request. The new path reuses the same latch so a single warn
// is emitted even under sustained Redis outage. Anchor the search on the
// catch handler that surrounds the RATE_LIMITER warn so we exercise the
// exact code path that produced the user's log flood.
const fenceIdx = src.indexOf("RATE_LIMITER");
assert.ok(fenceIdx > 0, "RATE_LIMITER fail-open warn should exist");
const windowStart = Math.max(0, fenceIdx - 200);
const windowEnd = Math.min(src.length, fenceIdx + 200);
const window = src.slice(windowStart, windowEnd);
assert.ok(
/if \(!redisErrorLogged\)/.test(window),
"Rate limiter eval failure must check the dedup latch before logging"
);
});