* feat(gateway): add auth rate-limiting & brute-force protection Add a per-IP sliding-window rate limiter to Gateway authentication endpoints (HTTP, WebSocket upgrade, and WS message-level auth). When gateway.auth.rateLimit is configured, failed auth attempts are tracked per client IP. Once the threshold is exceeded within the sliding window, further attempts are blocked with HTTP 429 + Retry-After until the lockout period expires. Loopback addresses are exempt by default so local CLI sessions are never locked out. The limiter is only created when explicitly configured (undefined otherwise), keeping the feature fully opt-in and backward-compatible. * fix(gateway): isolate auth rate-limit scopes and normalize 429 responses --------- Co-authored-by: buerbaumer <buerbaumer@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
219 lines
7.0 KiB
TypeScript
219 lines
7.0 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 } 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;
|
||
}
|
||
|
||
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 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 entries = new Map<string, RateLimitEntry>();
|
||
|
||
// Periodic cleanup to avoid unbounded map growth.
|
||
const pruneTimer = setInterval(() => prune(), PRUNE_INTERVAL_MS);
|
||
// 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 (ip ?? "").trim() || "unknown";
|
||
}
|
||
|
||
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 {
|
||
clearInterval(pruneTimer);
|
||
entries.clear();
|
||
}
|
||
|
||
return { check, recordFailure, reset, size, prune, dispose };
|
||
}
|