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>
This commit is contained in:
@@ -22,7 +22,7 @@ type CreateRateLimiterOptions = {
|
||||
};
|
||||
|
||||
export interface RateLimiter {
|
||||
(key: string): Promise<RateLimitResult>;
|
||||
(key: string | readonly string[]): Promise<RateLimitResult>;
|
||||
reset(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -212,27 +212,19 @@ export function createRateLimiter(
|
||||
|
||||
// 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).
|
||||
// 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 / 10)),
|
||||
Math.max(1, Math.floor(maxRequests / 2)),
|
||||
);
|
||||
let redisDegraded = false;
|
||||
|
||||
const check = (async (key: string) => {
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
if (!normalizedKey) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests,
|
||||
resetAt: new Date(Date.now() + windowMs),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOne(normalizedKey: string): Promise<RateLimitResult> {
|
||||
if (!redisBackend) {
|
||||
return memoryBackend.check(normalizedKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await redisBackend.check(normalizedKey);
|
||||
if (redisDegraded) {
|
||||
@@ -244,6 +236,44 @@ export function createRateLimiter(
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user