Files
openclaw/src/gateway/auth-rate-limit.ts

233 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
}