233 lines
7.6 KiB
TypeScript
233 lines
7.6 KiB
TypeScript
/**
|
||
* In-memory sliding-window rate limiter for gateway authentication attempts.
|
||
*
|
||
* Tracks failed auth attempts by {scope, clientIp}. A scope lets callers keep
|
||
* independent counters for different credential classes (for example, shared
|
||
* gateway token/password vs device-token auth) while still sharing one
|
||
* limiter instance.
|
||
*
|
||
* Design decisions:
|
||
* - Pure in-memory Map – no external dependencies; suitable for a single
|
||
* gateway process. The Map is periodically pruned to avoid unbounded
|
||
* growth.
|
||
* - Loopback addresses (127.0.0.1 / ::1) are exempt by default so that local
|
||
* CLI sessions are never locked out.
|
||
* - The module is side-effect-free: callers create an instance via
|
||
* {@link createAuthRateLimiter} and pass it where needed.
|
||
*/
|
||
|
||
import { isLoopbackAddress, resolveClientIp } from "./net.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface RateLimitConfig {
|
||
/** Maximum failed attempts before blocking. @default 10 */
|
||
maxAttempts?: number;
|
||
/** Sliding window duration in milliseconds. @default 60_000 (1 min) */
|
||
windowMs?: number;
|
||
/** Lockout duration in milliseconds after the limit is exceeded. @default 300_000 (5 min) */
|
||
lockoutMs?: number;
|
||
/** Exempt loopback (localhost) addresses from rate limiting. @default true */
|
||
exemptLoopback?: boolean;
|
||
/** Background prune interval in milliseconds; set <= 0 to disable auto-prune. @default 60_000 */
|
||
pruneIntervalMs?: number;
|
||
}
|
||
|
||
export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
|
||
export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret";
|
||
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
|
||
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
|
||
|
||
export interface RateLimitEntry {
|
||
/** Timestamps (epoch ms) of recent failed attempts inside the window. */
|
||
attempts: number[];
|
||
/** If set, requests from this IP are blocked until this epoch-ms instant. */
|
||
lockedUntil?: number;
|
||
}
|
||
|
||
export interface RateLimitCheckResult {
|
||
/** Whether the request is allowed to proceed. */
|
||
allowed: boolean;
|
||
/** Number of remaining attempts before the limit is reached. */
|
||
remaining: number;
|
||
/** Milliseconds until the lockout expires (0 when not locked). */
|
||
retryAfterMs: number;
|
||
}
|
||
|
||
export interface AuthRateLimiter {
|
||
/** Check whether `ip` is currently allowed to attempt authentication. */
|
||
check(ip: string | undefined, scope?: string): RateLimitCheckResult;
|
||
/** Record a failed authentication attempt for `ip`. */
|
||
recordFailure(ip: string | undefined, scope?: string): void;
|
||
/** Reset the rate-limit state for `ip` (e.g. after a successful login). */
|
||
reset(ip: string | undefined, scope?: string): void;
|
||
/** Return the current number of tracked IPs (useful for diagnostics). */
|
||
size(): number;
|
||
/** Remove expired entries and release memory. */
|
||
prune(): void;
|
||
/** Dispose the limiter and cancel periodic cleanup timers. */
|
||
dispose(): void;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defaults
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DEFAULT_MAX_ATTEMPTS = 10;
|
||
const DEFAULT_WINDOW_MS = 60_000; // 1 minute
|
||
const DEFAULT_LOCKOUT_MS = 300_000; // 5 minutes
|
||
const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Implementation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Canonicalize client IPs used for auth throttling so all call sites
|
||
* share one representation (including IPv4-mapped IPv6 forms).
|
||
*/
|
||
export function normalizeRateLimitClientIp(ip: string | undefined): string {
|
||
return resolveClientIp({ remoteAddr: ip }) ?? "unknown";
|
||
}
|
||
|
||
export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter {
|
||
const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||
const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
||
const lockoutMs = config?.lockoutMs ?? DEFAULT_LOCKOUT_MS;
|
||
const exemptLoopback = config?.exemptLoopback ?? true;
|
||
const pruneIntervalMs = config?.pruneIntervalMs ?? PRUNE_INTERVAL_MS;
|
||
|
||
const entries = new Map<string, RateLimitEntry>();
|
||
|
||
// Periodic cleanup to avoid unbounded map growth.
|
||
const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null;
|
||
// Allow the Node.js process to exit even if the timer is still active.
|
||
if (pruneTimer?.unref) {
|
||
pruneTimer.unref();
|
||
}
|
||
|
||
function normalizeScope(scope: string | undefined): string {
|
||
return (scope ?? AUTH_RATE_LIMIT_SCOPE_DEFAULT).trim() || AUTH_RATE_LIMIT_SCOPE_DEFAULT;
|
||
}
|
||
|
||
function normalizeIp(ip: string | undefined): string {
|
||
return normalizeRateLimitClientIp(ip);
|
||
}
|
||
|
||
function resolveKey(
|
||
rawIp: string | undefined,
|
||
rawScope: string | undefined,
|
||
): {
|
||
key: string;
|
||
ip: string;
|
||
} {
|
||
const ip = normalizeIp(rawIp);
|
||
const scope = normalizeScope(rawScope);
|
||
return { key: `${scope}:${ip}`, ip };
|
||
}
|
||
|
||
function isExempt(ip: string): boolean {
|
||
return exemptLoopback && isLoopbackAddress(ip);
|
||
}
|
||
|
||
function slideWindow(entry: RateLimitEntry, now: number): void {
|
||
const cutoff = now - windowMs;
|
||
// Remove attempts that fell outside the window.
|
||
entry.attempts = entry.attempts.filter((ts) => ts > cutoff);
|
||
}
|
||
|
||
function check(rawIp: string | undefined, rawScope?: string): RateLimitCheckResult {
|
||
const { key, ip } = resolveKey(rawIp, rawScope);
|
||
if (isExempt(ip)) {
|
||
return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 };
|
||
}
|
||
|
||
const now = Date.now();
|
||
const entry = entries.get(key);
|
||
|
||
if (!entry) {
|
||
return { allowed: true, remaining: maxAttempts, retryAfterMs: 0 };
|
||
}
|
||
|
||
// Still locked out?
|
||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||
return {
|
||
allowed: false,
|
||
remaining: 0,
|
||
retryAfterMs: entry.lockedUntil - now,
|
||
};
|
||
}
|
||
|
||
// Lockout expired – clear it.
|
||
if (entry.lockedUntil && now >= entry.lockedUntil) {
|
||
entry.lockedUntil = undefined;
|
||
entry.attempts = [];
|
||
}
|
||
|
||
slideWindow(entry, now);
|
||
const remaining = Math.max(0, maxAttempts - entry.attempts.length);
|
||
return { allowed: remaining > 0, remaining, retryAfterMs: 0 };
|
||
}
|
||
|
||
function recordFailure(rawIp: string | undefined, rawScope?: string): void {
|
||
const { key, ip } = resolveKey(rawIp, rawScope);
|
||
if (isExempt(ip)) {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
let entry = entries.get(key);
|
||
|
||
if (!entry) {
|
||
entry = { attempts: [] };
|
||
entries.set(key, entry);
|
||
}
|
||
|
||
// If currently locked, do nothing (already blocked).
|
||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||
return;
|
||
}
|
||
|
||
slideWindow(entry, now);
|
||
entry.attempts.push(now);
|
||
|
||
if (entry.attempts.length >= maxAttempts) {
|
||
entry.lockedUntil = now + lockoutMs;
|
||
}
|
||
}
|
||
|
||
function reset(rawIp: string | undefined, rawScope?: string): void {
|
||
const { key } = resolveKey(rawIp, rawScope);
|
||
entries.delete(key);
|
||
}
|
||
|
||
function prune(): void {
|
||
const now = Date.now();
|
||
for (const [key, entry] of entries) {
|
||
// If locked out, keep the entry until the lockout expires.
|
||
if (entry.lockedUntil && now < entry.lockedUntil) {
|
||
continue;
|
||
}
|
||
slideWindow(entry, now);
|
||
if (entry.attempts.length === 0) {
|
||
entries.delete(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
function size(): number {
|
||
return entries.size;
|
||
}
|
||
|
||
function dispose(): void {
|
||
if (pruneTimer) {
|
||
clearInterval(pruneTimer);
|
||
}
|
||
entries.clear();
|
||
}
|
||
|
||
return { check, recordFailure, reset, size, prune, dispose };
|
||
}
|