openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
150 lines (149 loc) • 5.28 kB
JavaScript
import { j as resolveTimerTimeoutMs } from "./number-coercion-CJQ8TR--.js";
import "./number-coercion-Z7n6tXLk.js";
import { f as resolveClientIp, r as isLoopbackAddress } from "./net-DTe7AQiu.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 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.
*/
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_BOOTSTRAP_TOKEN = "bootstrap-token";
const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
const BROWSER_ORIGIN_RATE_LIMIT_KEY_PREFIX = "browser-origin:";
const DEFAULT_MAX_ATTEMPTS = 10;
const DEFAULT_WINDOW_MS = 6e4;
const DEFAULT_LOCKOUT_MS = 3e5;
const PRUNE_INTERVAL_MS = 6e4;
/**
* 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)) return ip;
return resolveClientIp({ remoteAddr: ip }) ?? "unknown";
}
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 createAuthRateLimiter(config) {
const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const windowMs = resolveTimerTimeoutMs(config?.windowMs, DEFAULT_WINDOW_MS, 0);
const lockoutMs = resolveTimerTimeoutMs(config?.lockoutMs, DEFAULT_LOCKOUT_MS, 0);
const exemptLoopback = config?.exemptLoopback ?? true;
const pruneIntervalMs = resolvePruneIntervalMs(config?.pruneIntervalMs);
const entries = /* @__PURE__ */ new Map();
const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null;
if (pruneTimer?.unref) pruneTimer.unref();
function normalizeScope(scope) {
return (scope ?? "default").trim() || "default";
}
function normalizeIp(ip) {
return normalizeRateLimitClientIp(ip);
}
function resolveKey(rawIp, rawScope) {
const ip = normalizeIp(rawIp);
return {
key: `${normalizeScope(rawScope)}:${ip}`,
ip
};
}
function isExempt(ip) {
return exemptLoopback && isLoopbackAddress(ip);
}
function slideWindow(entry, now) {
const cutoff = now - 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: maxAttempts,
retryAfterMs: 0
};
const now = Date.now();
const entry = entries.get(key);
if (!entry) return {
allowed: true,
remaining: maxAttempts,
retryAfterMs: 0
};
if (entry.lockedUntil && now < entry.lockedUntil) return {
allowed: false,
remaining: 0,
retryAfterMs: entry.lockedUntil - now
};
if (entry.lockedUntil && now >= entry.lockedUntil) {
entry.lockedUntil = void 0;
entry.attempts = [];
}
slideWindow(entry, now);
const remaining = Math.max(0, maxAttempts - entry.attempts.length);
return {
allowed: remaining > 0,
remaining,
retryAfterMs: 0
};
}
function recordFailure(rawIp, rawScope) {
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 (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, rawScope) {
const { key } = resolveKey(rawIp, rawScope);
entries.delete(key);
}
function prune() {
const now = Date.now();
for (const [key, entry] of entries) {
if (entry.lockedUntil && now < entry.lockedUntil) continue;
slideWindow(entry, now);
if (entry.attempts.length === 0) entries.delete(key);
}
}
function size() {
return entries.size;
}
function dispose() {
if (pruneTimer) clearInterval(pruneTimer);
entries.clear();
}
return {
check,
recordFailure,
reset,
size,
prune,
dispose
};
}
//#endregion
export { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING as a, normalizeRateLimitClientIp as c, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH as i, AUTH_RATE_LIMIT_SCOPE_DEFAULT as n, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET as o, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN as r, createAuthRateLimiter as s, AUTH_RATE_LIMIT_SCOPE_BOOTSTRAP_TOKEN as t };