Files
Nexus/packages/api/src/middleware/rate-limit.ts
T
Hartmut 3c5d1d37f7 security: rate-limit IP-keyed, fail-closed on empty key (#37)
Rate-limiter now accepts string | string[] so callers can key on
multiple buckets simultaneously. If any bucket is exhausted the
request is denied, which lets login/TOTP/reset-password throttle on
BOTH user identifier and source IP without either becoming a bypass.

Fail-closed: empty/whitespace-only keys now deny by default instead
of silently allowing unbounded attempts (was CWE-307 gap).

Degraded-fallback divisor reduced from /10 to /2 — the old aggressive
clamp forced-logged-out legitimate users during brief Redis outages;
/2 still meaningfully slows distributed brute-force.

Callers updated:
- auth.ts (login): both email: and ip: buckets
- auth router requestPasswordReset: email + IP
- auth router resetPassword: IP before lookup, email-reset after
- invite router getInvite/acceptInvite: IP
- user-self-service verifyTotp: userId + IP

TRPCContext now carries clientIp; web tRPC route extracts it from
X-Forwarded-For / X-Real-IP.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 08:19:33 +02:00

308 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Redis } from "ioredis";
import { logger } from "../lib/logger.js";
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
}
type RateLimitEntry = {
count: number;
resetAt: number;
};
type RateLimitBackendMode = "auto" | "memory" | "redis";
type CreateRateLimiterOptions = {
name?: string;
backend?: RateLimitBackendMode;
redisUrl?: string;
keyPrefix?: string;
};
export interface RateLimiter {
(key: string | readonly string[]): Promise<RateLimitResult>;
reset(): Promise<void>;
}
type RateLimiterBackend = {
check: (key: string) => Promise<RateLimitResult>;
reset: () => Promise<void>;
};
const DEFAULT_REDIS_KEY_PREFIX = "capakraken:ratelimit";
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
const warnedRedisFailures = new Set<string>();
let sharedRedisClient: Redis | null = null;
function getBackendMode(requestedBackend: RateLimitBackendMode | undefined): RateLimitBackendMode {
if (
requestedBackend === "memory" ||
requestedBackend === "redis" ||
requestedBackend === "auto"
) {
return requestedBackend;
}
if (
DEFAULT_REDIS_BACKEND === "memory" ||
DEFAULT_REDIS_BACKEND === "redis" ||
DEFAULT_REDIS_BACKEND === "auto"
) {
return DEFAULT_REDIS_BACKEND;
}
return "auto";
}
function sanitizeKeySegment(value: string): string {
return value.replace(/[^a-zA-Z0-9:_-]/g, "_");
}
function getRedisClient(redisUrl: string): Redis {
if (!sharedRedisClient) {
sharedRedisClient = new Redis(redisUrl, {
lazyConnect: false,
enableReadyCheck: false,
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
commandTimeout: 1000,
});
sharedRedisClient.on("error", (error: unknown) => {
logger.warn({ err: error, redisUrl }, "Rate limiter Redis connection emitted an error");
});
}
return sharedRedisClient;
}
function createMemoryBackend(windowMs: number, maxRequests: number): RateLimiterBackend {
const store = new Map<string, RateLimitEntry>();
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key);
}
}
}, windowMs);
if (cleanupInterval.unref) {
cleanupInterval.unref();
}
return {
async check(key: string) {
const now = Date.now();
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
const resetAt = now + windowMs;
store.set(key, { count: 1, resetAt });
return {
allowed: true,
remaining: maxRequests - 1,
resetAt: new Date(resetAt),
};
}
existing.count += 1;
return {
allowed: existing.count <= maxRequests,
remaining: Math.max(0, maxRequests - existing.count),
resetAt: new Date(existing.resetAt),
};
},
async reset() {
store.clear();
},
};
}
function createRedisBackend(
windowMs: number,
maxRequests: number,
options: Required<Pick<CreateRateLimiterOptions, "name" | "redisUrl" | "keyPrefix">>,
): RateLimiterBackend {
const redisKeyPrefix = `${options.keyPrefix}:${sanitizeKeySegment(options.name)}`;
const warningKey = `${options.name}:${options.redisUrl}`;
async function runRedisCheck(key: string): Promise<RateLimitResult> {
const client = getRedisClient(options.redisUrl);
const redisKey = `${redisKeyPrefix}:${key}`;
const result = (await client.eval(
`
local current = redis.call("INCR", KEYS[1])
local ttl = redis.call("PTTL", KEYS[1])
if ttl < 0 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {current, ttl}
`,
1,
redisKey,
String(windowMs),
)) as [number | string, number | string];
const count = Number(result[0]);
const ttlMs = Math.max(0, Number(result[1]));
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
resetAt: new Date(Date.now() + ttlMs),
};
}
async function resetRedisKeys(): Promise<void> {
const client = getRedisClient(options.redisUrl);
const matchPattern = `${redisKeyPrefix}:*`;
let cursor = "0";
do {
const [nextCursor, keys] = await client.scan(cursor, "MATCH", matchPattern, "COUNT", 100);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
}
} while (cursor !== "0");
}
return {
async check(key: string) {
try {
return await runRedisCheck(key);
} catch (error) {
if (!warnedRedisFailures.has(warningKey)) {
warnedRedisFailures.add(warningKey);
logger.error(
{ err: error, redisUrl: options.redisUrl, limiter: options.name },
"Rate limiter Redis backend unavailable, falling back to degraded in-memory counters",
);
}
throw error;
}
},
async reset() {
await resetRedisKeys();
},
};
}
/**
* Creates a rate limiter.
* Uses a Redis-backed shared counter when `REDIS_URL` is configured (or `backend: "redis"` is selected),
* and falls back to in-memory counters when Redis is unavailable or intentionally disabled.
*/
export function createRateLimiter(
windowMs: number,
maxRequests: number,
options: CreateRateLimiterOptions = {},
): RateLimiter {
const name = options.name ?? `window-${windowMs}-max-${maxRequests}`;
const memoryBackend = createMemoryBackend(windowMs, maxRequests);
const backendMode = getBackendMode(options.backend);
const redisUrl = options.redisUrl?.trim() || DEFAULT_REDIS_URL;
const keyPrefix = options.keyPrefix ?? DEFAULT_REDIS_KEY_PREFIX;
const shouldUseRedis = backendMode === "redis" || (backendMode === "auto" && Boolean(redisUrl));
const redisBackend =
shouldUseRedis && redisUrl
? createRedisBackend(windowMs, maxRequests, { name, redisUrl, keyPrefix })
: null;
// When Redis is unavailable, apply a stricter limit to compensate for
// per-node isolation (each process keeps independent in-memory counters,
// so the effective cluster-wide limit is maxRequests × nodeCount). A
// /2 divisor keeps legitimate users out of forced-logout while still
// meaningfully slowing distributed brute-force during Redis outages.
const degradedMemoryBackend = createMemoryBackend(
windowMs,
Math.max(1, Math.floor(maxRequests / 2)),
);
let redisDegraded = false;
async function checkOne(normalizedKey: string): Promise<RateLimitResult> {
if (!redisBackend) {
return memoryBackend.check(normalizedKey);
}
try {
const result = await redisBackend.check(normalizedKey);
if (redisDegraded) {
redisDegraded = false;
logger.info({ limiter: name }, "Rate limiter Redis backend recovered");
}
return result;
} catch {
redisDegraded = true;
return degradedMemoryBackend.check(normalizedKey);
}
}
const check = (async (key: string | readonly string[]) => {
const rawKeys = Array.isArray(key) ? key : [key as string];
const normalizedKeys = rawKeys
.map((k) => (typeof k === "string" ? k.trim().toLowerCase() : ""))
.filter((k) => k.length > 0);
// Fail-closed: if every supplied key is empty or whitespace the caller
// has no identity to throttle; deny rather than letting unbounded
// attempts through (CWE-307).
if (normalizedKeys.length === 0) {
logger.warn({ limiter: name }, "Rate limiter called with empty key — denying by default");
return {
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + windowMs),
};
}
// Check every bucket. If any bucket is exhausted, the request is
// denied; this allows callers to key on both user identifier AND
// request IP without either becoming a bypass.
let denied: RateLimitResult | null = null;
let earliestReset = new Date(Date.now() + windowMs);
let minRemaining = Number.POSITIVE_INFINITY;
for (const normalizedKey of normalizedKeys) {
const result = await checkOne(normalizedKey);
if (!result.allowed && !denied) denied = result;
if (result.resetAt < earliestReset) earliestReset = result.resetAt;
if (result.remaining < minRemaining) minRemaining = result.remaining;
}
if (denied) return denied;
return {
allowed: true,
remaining: minRemaining === Number.POSITIVE_INFINITY ? maxRequests : minRemaining,
resetAt: earliestReset,
};
}) as RateLimiter;
check.reset = async () => {
await memoryBackend.reset();
if (redisBackend) {
try {
await redisBackend.reset();
} catch {
// Ignore Redis reset errors; tests and local fallback must remain usable.
}
}
};
return check;
}
/** General API rate limiter: 100 requests per 15 minutes per user. */
export const apiRateLimiter = createRateLimiter(15 * 60 * 1000, 100, {
name: "api",
});
/** Auth rate limiter: 5 attempts per 15 minutes per login identifier. */
export const authRateLimiter = createRateLimiter(15 * 60 * 1000, 5, {
name: "auth",
});
/** TOTP verification rate limiter: 10 attempts per 30 seconds per userId.
* Applied to the public verifyTotp endpoint to prevent brute-force of 6-digit codes. */
export const totpRateLimiter = createRateLimiter(30 * 1000, 10, {
name: "totp-verify",
});