mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
79
tests/unit/rate-limiter-redis-optional.test.ts
Normal file
79
tests/unit/rate-limiter-redis-optional.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user