Spaces:
Runtime error
Runtime error
File size: 3,861 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | /**
* Login brute-force guard.
*
* Tracks failed `/api/auth/login` attempts per client IP in process memory
* and returns lockout decisions. Single-process scope is intentional — this
* is a defense-in-depth check that pairs with Cloudflare/reverse-proxy rate
* limiting, not a substitute for it.
*
* Tunables:
* - failure threshold: 5 within `WINDOW_MS`
* - lockout duration: `LOCKOUT_MS`
* - sliding window: `WINDOW_MS`
*
* The guard is a no-op when `enabled` is false; the caller decides based on
* the `bruteForceProtection` setting (default true).
*/
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 15 * 60 * 1000;
const FAILURE_THRESHOLD = 5;
interface AttemptState {
count: number;
firstAttemptAt: number;
lockedUntil: number | null;
}
const attempts: Map<string, AttemptState> = new Map();
// Above this many tracked IPs, opportunistically drop entries whose window has elapsed and
// that are not currently locked. Without this the map only ever grew (entries were deleted
// only on a *successful* login), so every distinct IP that ever failed a login leaked a
// permanent entry — unbounded under distributed brute-force. Expired/unlocked entries are
// already treated as "allowed", so removing them never changes a guard decision.
const PRUNE_THRESHOLD = 256;
function pruneExpiredAttempts(now: number): void {
for (const [key, state] of attempts) {
const windowElapsed = now - state.firstAttemptAt > WINDOW_MS;
const notLocked = !state.lockedUntil || state.lockedUntil <= now;
if (windowElapsed && notLocked) attempts.delete(key);
}
}
export interface GuardDecision {
allowed: boolean;
retryAfterSeconds?: number;
}
function nowMs(): number {
return Date.now();
}
function clientKey(rawIp: string | null | undefined): string {
const ip = (rawIp || "").trim();
return ip || "__unknown__";
}
export function checkLoginGuard(
rawIp: string | null | undefined,
options: { enabled: boolean }
): GuardDecision {
if (!options.enabled) return { allowed: true };
const state = attempts.get(clientKey(rawIp));
if (!state) return { allowed: true };
const now = nowMs();
if (state.lockedUntil && state.lockedUntil > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((state.lockedUntil - now) / 1000),
};
}
return { allowed: true };
}
export function recordLoginFailure(
rawIp: string | null | undefined,
options: { enabled: boolean }
): GuardDecision {
if (!options.enabled) return { allowed: true };
const key = clientKey(rawIp);
const now = nowMs();
// Keep the map from growing without bound as distinct IPs fail logins over time.
if (attempts.size > PRUNE_THRESHOLD) pruneExpiredAttempts(now);
const existing = attempts.get(key);
if (!existing || now - existing.firstAttemptAt > WINDOW_MS) {
attempts.set(key, { count: 1, firstAttemptAt: now, lockedUntil: null });
return { allowed: true };
}
const nextCount = existing.count + 1;
if (nextCount >= FAILURE_THRESHOLD) {
const lockedUntil = now + LOCKOUT_MS;
attempts.set(key, {
count: nextCount,
firstAttemptAt: existing.firstAttemptAt,
lockedUntil,
});
return { allowed: false, retryAfterSeconds: Math.ceil(LOCKOUT_MS / 1000) };
}
attempts.set(key, {
count: nextCount,
firstAttemptAt: existing.firstAttemptAt,
lockedUntil: null,
});
return { allowed: true };
}
export function clearLoginAttempts(rawIp: string | null | undefined): void {
attempts.delete(clientKey(rawIp));
}
export function resetLoginGuardForTests(): void {
attempts.clear();
}
/** Test-only: current number of tracked IP entries. */
export function getLoginGuardSizeForTests(): number {
return attempts.size;
}
export const LOGIN_GUARD_TUNABLES = Object.freeze({
WINDOW_MS,
LOCKOUT_MS,
FAILURE_THRESHOLD,
});
|