Files
overlord/Overlord-Server/src/rateLimit.ts
T

130 lines
3.4 KiB
TypeScript
Raw Normal View History

2026-03-05 00:04:26 -06:00
import { logger } from "./logger";
2026-03-06 20:50:02 -06:00
import { getConfig } from "./config";
2026-03-05 00:04:26 -06:00
interface RateLimitEntry {
attempts: number;
firstAttempt: number;
lockedUntil?: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const CLEANUP_INTERVAL = 60 * 1000;
2026-03-06 20:50:02 -06:00
function getRateLimitPolicy() {
const security = getConfig().security;
const maxAttempts = Math.min(50, Math.max(1, Number(security.loginMaxAttempts) || 5));
const windowMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginWindowMinutes) || 15) * 60 * 1000));
const lockoutMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginLockoutMinutes) || 30) * 60 * 1000));
return { maxAttempts, windowMs, lockoutMs };
}
2026-03-05 00:04:26 -06:00
export function isRateLimited(ip: string): {
limited: boolean;
retryAfter?: number;
} {
2026-03-06 20:50:02 -06:00
const policy = getRateLimitPolicy();
2026-03-05 00:04:26 -06:00
const entry = rateLimitStore.get(ip);
if (!entry) {
return { limited: false };
}
const now = Date.now();
if (entry.lockedUntil && entry.lockedUntil > now) {
const retryAfter = Math.ceil((entry.lockedUntil - now) / 1000);
return { limited: true, retryAfter };
}
2026-03-06 20:50:02 -06:00
if (now - entry.firstAttempt > policy.windowMs) {
2026-03-05 00:04:26 -06:00
rateLimitStore.delete(ip);
return { limited: false };
}
2026-03-06 20:50:02 -06:00
if (entry.attempts >= policy.maxAttempts) {
entry.lockedUntil = now + policy.lockoutMs;
const retryAfter = Math.ceil(policy.lockoutMs / 1000);
2026-03-05 00:04:26 -06:00
logger.warn(
`[rate-limit] IP ${ip} locked out for ${retryAfter}s after ${entry.attempts} failed attempts`,
);
return { limited: true, retryAfter };
}
return { limited: false };
}
export function recordFailedAttempt(ip: string): void {
2026-03-06 20:50:02 -06:00
const policy = getRateLimitPolicy();
2026-03-05 00:04:26 -06:00
const now = Date.now();
const entry = rateLimitStore.get(ip);
if (!entry) {
rateLimitStore.set(ip, {
attempts: 1,
firstAttempt: now,
});
return;
}
2026-03-06 20:50:02 -06:00
if (now - entry.firstAttempt > policy.windowMs) {
2026-03-05 00:04:26 -06:00
rateLimitStore.set(ip, {
attempts: 1,
firstAttempt: now,
});
return;
}
entry.attempts++;
logger.debug(
2026-03-06 20:50:02 -06:00
`[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${policy.maxAttempts}`,
2026-03-05 00:04:26 -06:00
);
}
export function recordSuccessfulAttempt(ip: string): void {
rateLimitStore.delete(ip);
}
function cleanupExpired(): void {
2026-03-06 20:50:02 -06:00
const policy = getRateLimitPolicy();
2026-03-05 00:04:26 -06:00
const now = Date.now();
let cleaned = 0;
for (const [ip, entry] of rateLimitStore.entries()) {
2026-03-06 20:50:02 -06:00
const windowExpired = now - entry.firstAttempt > policy.windowMs;
2026-03-05 00:04:26 -06:00
const lockoutExpired = entry.lockedUntil && entry.lockedUntil < now;
if ((windowExpired && !entry.lockedUntil) || lockoutExpired) {
rateLimitStore.delete(ip);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug(`[rate-limit] Cleaned up ${cleaned} expired entries`);
}
}
export function getRateLimitStats(): { total: number; locked: number } {
const now = Date.now();
let locked = 0;
for (const entry of rateLimitStore.values()) {
if (entry.lockedUntil && entry.lockedUntil > now) {
locked++;
}
}
return {
total: rateLimitStore.size,
locked,
};
}
setInterval(cleanupExpired, CLEANUP_INTERVAL);
2026-03-06 20:50:02 -06:00
const initialPolicy = getRateLimitPolicy();
2026-03-05 00:04:26 -06:00
logger.info(
2026-03-06 20:50:02 -06:00
`[rate-limit] Initialized: ${initialPolicy.maxAttempts} attempts per ${Math.round(initialPolicy.windowMs / 60000)} minutes, ${Math.round(initialPolicy.lockoutMs / 60000)} minute lockout`,
2026-03-05 00:04:26 -06:00
);