UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

73 lines (72 loc) 2.81 kB
//#region src/gateway/control-plane-rate-limit.ts const CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS = 3; const CONTROL_PLANE_RATE_LIMIT_WINDOW_MS = 6e4; const CONTROL_PLANE_BUCKET_MAX_STALE_MS = 5 * 6e4; /** Hard cap to prevent memory DoS from rapid unique-key injection (CWE-400). */ const CONTROL_PLANE_BUCKET_MAX_ENTRIES = 1e4; const controlPlaneBuckets = /* @__PURE__ */ new Map(); function normalizePart(value, fallback) { if (typeof value !== "string") return fallback; const normalized = value.trim(); return normalized.length > 0 ? normalized : fallback; } /** Builds a stable throttle key while avoiding shared fallback buckets for anonymous clients. */ function resolveControlPlaneRateLimitKey(client) { const deviceId = normalizePart(client?.connect?.device?.id, "unknown-device"); const clientIp = normalizePart(client?.clientIp, "unknown-ip"); if (deviceId === "unknown-device" && clientIp === "unknown-ip") { const connId = normalizePart(client?.connId, ""); if (connId) return `${deviceId}|${clientIp}|conn=${connId}`; } return `${deviceId}|${clientIp}`; } /** Consumes one write budget unit and reports retry state for gateway error responses. */ function consumeControlPlaneWriteBudget(params) { const nowMs = params.nowMs ?? Date.now(); const key = resolveControlPlaneRateLimitKey(params.client); const bucket = controlPlaneBuckets.get(key); if (!bucket || nowMs - bucket.windowStartMs >= CONTROL_PLANE_RATE_LIMIT_WINDOW_MS) { if (!controlPlaneBuckets.has(key) && controlPlaneBuckets.size >= CONTROL_PLANE_BUCKET_MAX_ENTRIES) { const oldest = controlPlaneBuckets.keys().next().value; if (oldest !== void 0) controlPlaneBuckets.delete(oldest); } controlPlaneBuckets.set(key, { count: 1, windowStartMs: nowMs }); return { allowed: true, retryAfterMs: 0, remaining: CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS - 1, key }; } if (bucket.count >= CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS) return { allowed: false, retryAfterMs: Math.max(0, bucket.windowStartMs + CONTROL_PLANE_RATE_LIMIT_WINDOW_MS - nowMs), remaining: 0, key }; bucket.count += 1; return { allowed: true, retryAfterMs: 0, remaining: Math.max(0, CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS - bucket.count), key }; } /** * Remove buckets whose rate-limit window expired more than * CONTROL_PLANE_BUCKET_MAX_STALE_MS ago. Called periodically * by the gateway maintenance timer to prevent unbounded growth. */ function pruneStaleControlPlaneBuckets(nowMs = Date.now()) { let pruned = 0; for (const [key, bucket] of controlPlaneBuckets) if (nowMs - bucket.windowStartMs > CONTROL_PLANE_BUCKET_MAX_STALE_MS) { controlPlaneBuckets.delete(key); pruned += 1; } return pruned; } //#endregion export { pruneStaleControlPlaneBuckets as n, consumeControlPlaneWriteBudget as t };