UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

253 lines (252 loc) 9.91 kB
import { F as resolveTimerTimeoutMs, j as resolveIntegerOption } from "./number-coercion-CLj0HTDM.js"; import { t as createDeferredCore } from "./deferred-D0La5CRk.js"; import { a as isLoopbackAddress, h as resolveClientIp } from "./net-DbNPs6Xm.js"; //#region src/gateway/auth-rate-limit.ts /** * 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 and capped to avoid * unbounded growth. * - Loopback addresses (127.0.0.1 / ::1) are exempt from denial by default so * local CLI sessions are never locked out. Failed auth still incurs a * bounded, escalating delay. * - The module is side-effect-free: callers create an instance via * {@link createAuthRateLimiter} and pass it where needed. */ const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default"; const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret"; const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token"; const AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING = "node-pairing"; const AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL = "node-reapproval"; const AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN = "bootstrap-token"; const AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN = "device-join"; const AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE = "watch-challenge"; const AUTH_RATE_LIMIT_SCOPE_WORKER_ADMISSION = "worker-admission"; const AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER = "worker-transfer"; const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth"; const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:"; const IDENTITY_RATE_LIMIT_KEY_PREFIX = "identity:"; const authRateLimiterExemptionChecks = /* @__PURE__ */ new WeakMap(); /** Whether a limiter created by this module exempts the prepared client identity. */ function isAuthRateLimitClientExempt(limiter, ip) { return authRateLimiterExemptionChecks.get(limiter)?.(ip) ?? false; } const DEFAULT_MAX_ATTEMPTS = 10; const DEFAULT_WINDOW_MS = 6e4; const DEFAULT_LOCKOUT_MS = 3e5; const PRUNE_INTERVAL_MS = 6e4; const DEFAULT_MAX_ENTRIES = 1e4; const LOOPBACK_FAILURE_DELAY_BASE_MS = 250; const LOOPBACK_FAILURE_DELAY_MAX_MS = 5e3; const LOOPBACK_FAILURE_HISTORY_LIMIT = Math.ceil(Math.log2(LOOPBACK_FAILURE_DELAY_MAX_MS / LOOPBACK_FAILURE_DELAY_BASE_MS)) + 1; /** * Canonicalize client IPs used for auth throttling so all call sites * share one representation (including IPv4-mapped IPv6 forms). */ function normalizeRateLimitClientIp(ip) { if (typeof ip === "string" && (ip.startsWith(BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX) || ip.startsWith(IDENTITY_RATE_LIMIT_KEY_PREFIX))) return ip; return resolveClientIp({ remoteAddr: ip }) ?? "unknown"; } /** Build an opaque limiter identity that is not subject to loopback IP exemptions. */ function buildRateLimitIdentityKey(namespace, identity) { return `${IDENTITY_RATE_LIMIT_KEY_PREFIX}${namespace}:${identity}`; } function resolvePruneIntervalMs(value) { if (value === void 0) return PRUNE_INTERVAL_MS; if (Number.isFinite(value) && value <= 0) return 0; return resolveTimerTimeoutMs(value, PRUNE_INTERVAL_MS); } function resolveAuthRateLimitPolicy(config) { return { maxAttempts: config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, windowMs: resolveTimerTimeoutMs(config?.windowMs, DEFAULT_WINDOW_MS, 0), lockoutMs: resolveTimerTimeoutMs(config?.lockoutMs, DEFAULT_LOCKOUT_MS, 0), exemptLoopback: config?.exemptLoopback ?? true }; } function createAuthRateLimiter(config) { let policy = resolveAuthRateLimitPolicy(config); const pruneIntervalMs = resolvePruneIntervalMs(config?.pruneIntervalMs); const maxEntries = resolveIntegerOption(config?.maxEntries, DEFAULT_MAX_ENTRIES, { min: 1 }); const entries = /* @__PURE__ */ new Map(); const loopbackPenaltyWaiters = /* @__PURE__ */ new Map(); let overflowLockedUntil; const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null; if (pruneTimer?.unref) pruneTimer.unref(); function resolveKey(rawIp, rawScope) { const ip = normalizeRateLimitClientIp(rawIp); return { key: `${rawScope?.trim() || "default"}:${ip}`, ip }; } function isExempt(ip) { return policy.exemptLoopback && isLoopbackAddress(ip); } function refreshEntry(entry, now) { if (entry.lockedUntil && now >= entry.lockedUntil) { entry.lockedUntil = void 0; entry.attempts = []; } const cutoff = now - policy.windowMs; entry.attempts = entry.attempts.filter((ts) => ts > cutoff); } function check(rawIp, rawScope) { const { key, ip } = resolveKey(rawIp, rawScope); if (isExempt(ip)) return { allowed: true, remaining: policy.maxAttempts, retryAfterMs: 0 }; const now = Date.now(); const entry = entries.get(key); if (!entry) { const overflowLock = checkOverflowLock(now); if (overflowLock) return overflowLock; return { allowed: true, remaining: policy.maxAttempts, retryAfterMs: 0 }; } refreshEntry(entry, now); if (!entry.lockedUntil && entry.attempts.length >= policy.maxAttempts) entry.lockedUntil = now + policy.lockoutMs; if (entry.lockedUntil && now < entry.lockedUntil) return { allowed: false, remaining: 0, retryAfterMs: entry.lockedUntil - now }; const remaining = Math.max(0, policy.maxAttempts - entry.attempts.length); return { allowed: remaining > 0, remaining, retryAfterMs: 0 }; } function recordFailure(rawIp, rawScope) { const { key, ip } = resolveKey(rawIp, rawScope); const exempt = isExempt(ip); const now = Date.now(); let entry = entries.get(key); if (!entry) { if (!enforceMaxEntries(now)) { overflowLockedUntil = Math.max(overflowLockedUntil ?? 0, now + policy.lockoutMs); return; } entry = { attempts: [] }; entries.set(key, entry); } if (!exempt && entry.lockedUntil && now < entry.lockedUntil) return; refreshEntry(entry, now); entry.attempts.push(now); if (exempt && entry.attempts.length > LOOPBACK_FAILURE_HISTORY_LIMIT) entry.attempts.splice(0, entry.attempts.length - LOOPBACK_FAILURE_HISTORY_LIMIT); else if (!exempt && entry.attempts.length >= policy.maxAttempts) entry.lockedUntil = now + policy.lockoutMs; } function recordFailureAndDelay(rawIp, rawScope) { const { key, ip } = resolveKey(rawIp, rawScope); recordFailure(rawIp, rawScope); if (!isExempt(ip)) return Promise.resolve(); const failureCount = entries.get(key)?.attempts.length ?? 1; const penaltyMs = Math.min(LOOPBACK_FAILURE_DELAY_BASE_MS * 2 ** Math.min(failureCount - 1, 30), LOOPBACK_FAILURE_DELAY_MAX_MS); const deadline = Date.now() + penaltyMs; let waiters = loopbackPenaltyWaiters.get(key); if (!waiters) { waiters = { ...createDeferredCore(), deadline, timer: scheduleRelease(key, deadline) }; loopbackPenaltyWaiters.set(key, waiters); } else if (deadline > waiters.deadline) { waiters.deadline = deadline; clearTimeout(waiters.timer); waiters.timer = scheduleRelease(key, deadline); } return waiters.promise; } function scheduleRelease(key, deadline) { const timer = setTimeout(() => releaseLoopbackWaiters(key), Math.max(0, deadline - Date.now())); timer.unref?.(); return timer; } function releaseLoopbackWaiters(key) { const waiters = loopbackPenaltyWaiters.get(key); if (!waiters) return; loopbackPenaltyWaiters.delete(key); clearTimeout(waiters.timer); waiters.resolve(); } function reset(rawIp, rawScope) { const { key } = resolveKey(rawIp, rawScope); entries.delete(key); } function pruneExpiredEntries(now) { for (const [key, entry] of entries) { if (entry.lockedUntil && now < entry.lockedUntil) continue; refreshEntry(entry, now); if (entry.attempts.length === 0) entries.delete(key); } } function checkOverflowLock(now) { if (!overflowLockedUntil) return; if (now >= overflowLockedUntil) { overflowLockedUntil = void 0; return; } if (entries.size >= maxEntries) pruneExpiredEntries(now); if (entries.size < maxEntries) { overflowLockedUntil = void 0; return; } return { allowed: false, remaining: 0, retryAfterMs: overflowLockedUntil - now }; } function enforceMaxEntries(now) { if (entries.size < maxEntries) return true; pruneExpiredEntries(now); if (entries.size < maxEntries) return true; for (const [entryKey, entry] of entries) if (!entry.lockedUntil || now >= entry.lockedUntil) { entries.delete(entryKey); return true; } return false; } function prune() { pruneExpiredEntries(Date.now()); } function size() { return entries.size; } function dispose() { if (pruneTimer) clearInterval(pruneTimer); entries.clear(); overflowLockedUntil = void 0; for (const key of loopbackPenaltyWaiters.keys()) releaseLoopbackWaiters(key); } const limiter = { check, recordFailure, recordFailureAndDelay, reset, size, prune, dispose, updateConfig: (next) => { policy = resolveAuthRateLimitPolicy(next); } }; authRateLimiterExemptionChecks.set(limiter, (rawIp) => isExempt(normalizeRateLimitClientIp(rawIp))); return limiter; } //#endregion export { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH as a, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET as c, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER as d, buildRateLimitIdentityKey as f, normalizeRateLimitClientIp as h, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN as i, AUTH_RATE_LIMIT_SCOPE_WATCH_CHALLENGE as l, isAuthRateLimitClientExempt as m, AUTH_RATE_LIMIT_SCOPE_DEFAULT as n, AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING as o, createAuthRateLimiter as p, AUTH_RATE_LIMIT_SCOPE_DEVICE_JOIN as r, AUTH_RATE_LIMIT_SCOPE_NODE_REAPPROVAL as s, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN as t, AUTH_RATE_LIMIT_SCOPE_WORKER_ADMISSION as u };