fix(security): harden webhook memory guards across channels

This commit is contained in:
Peter Steinberger
2026-03-02 00:11:49 +00:00
parent 1c8ae978d2
commit 43cad8268d
14 changed files with 451 additions and 138 deletions

View File

@@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
- Security/Logging utility hardening: remove `eval`-based command execution from `scripts/clawlog.sh`, switch to argv-safe command construction, and escape predicate literals for user-supplied search/category filters to block local command/predicate injection paths.
- Security/ACPX Windows spawn hardening: resolve `.cmd/.bat` wrappers via PATH/PATHEXT and execute unwrapped Node/EXE entrypoints without shell parsing when possible, while preserving compatibility fallback for unknown custom wrappers by default and adding an opt-in strict mode (`strictWindowsCmdWrapper`) to fail closed for unresolvable wrappers.
- Security/Inbound metadata stripping: tighten sentinel matching and JSON-fence validation for inbound metadata stripping so user-authored lookalike lines no longer trigger unintended metadata removal.
- Security/Zalo webhook memory hardening: bound webhook security tracking state and normalize security keying to matched webhook paths (excluding attacker query-string churn) to prevent unauthenticated memory growth pressure on reachable webhook endpoints. Thanks @Somet2mes.
- Channels/Command parsing parity: align command-body parsing fields with channel command-gating text for Slack, Signal, Microsoft Teams, Mattermost, and BlueBubbles to avoid mention-strip mismatches and inconsistent command detection.
- CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973.
- Doctor/macOS state-dir safety: warn when OpenClaw state resolves inside iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...`, because sync-backed paths can cause slower I/O and lock/sync races. (#31004) Thanks @vincentkoc.

View File

@@ -3,6 +3,8 @@ import * as http from "http";
import * as Lark from "@larksuiteoapi/node-sdk";
import {
type ClawdbotConfig,
createBoundedCounter,
createFixedWindowRateLimiter,
type RuntimeEnv,
type HistoryEntry,
installRequestBodyLimitGuard,
@@ -32,6 +34,8 @@ const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
const FEISHU_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4_096;
const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
const FEISHU_WEBHOOK_COUNTER_MAX_TRACKED_KEYS = 4_096;
const FEISHU_WEBHOOK_COUNTER_TTL_MS = 6 * 60 * 60_000;
const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
export type FeishuReactionCreatedEvent = {
@@ -55,9 +59,15 @@ type ResolveReactionSyntheticEventParams = {
uuid?: () => string;
};
const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
const feishuWebhookStatusCounters = new Map<string, number>();
let lastWebhookRateLimitCleanupMs = 0;
const feishuWebhookRateLimiter = createFixedWindowRateLimiter({
windowMs: FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS,
maxRequests: FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS,
maxTrackedKeys: FEISHU_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS,
});
const feishuWebhookStatusCounters = createBoundedCounter({
maxTrackedKeys: FEISHU_WEBHOOK_COUNTER_MAX_TRACKED_KEYS,
ttlMs: FEISHU_WEBHOOK_COUNTER_TTL_MS,
});
function isJsonContentType(value: string | string[] | undefined): boolean {
const first = Array.isArray(value) ? value[0] : value;
@@ -68,55 +78,17 @@ function isJsonContentType(value: string | string[] | undefined): boolean {
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
}
function trimWebhookRateLimitState(): void {
while (feishuWebhookRateLimits.size > FEISHU_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS) {
const oldestKey = feishuWebhookRateLimits.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
feishuWebhookRateLimits.delete(oldestKey);
}
}
function maybePruneWebhookRateLimitState(nowMs: number): void {
if (
feishuWebhookRateLimits.size === 0 ||
nowMs - lastWebhookRateLimitCleanupMs < FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS
) {
return;
}
lastWebhookRateLimitCleanupMs = nowMs;
for (const [key, state] of feishuWebhookRateLimits) {
if (nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
feishuWebhookRateLimits.delete(key);
}
}
}
export function clearFeishuWebhookRateLimitStateForTest(): void {
feishuWebhookRateLimits.clear();
lastWebhookRateLimitCleanupMs = 0;
feishuWebhookRateLimiter.clear();
feishuWebhookStatusCounters.clear();
}
export function getFeishuWebhookRateLimitStateSizeForTest(): number {
return feishuWebhookRateLimits.size;
return feishuWebhookRateLimiter.size();
}
export function isWebhookRateLimitedForTest(key: string, nowMs: number): boolean {
maybePruneWebhookRateLimitState(nowMs);
const state = feishuWebhookRateLimits.get(key);
if (!state || nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
feishuWebhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
trimWebhookRateLimitState();
return false;
}
state.count += 1;
if (state.count > FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
return true;
}
return false;
return feishuWebhookRateLimiter.isRateLimited(key, nowMs);
}
function isWebhookRateLimited(key: string, nowMs: number): boolean {
@@ -133,8 +105,7 @@ function recordWebhookStatus(
return;
}
const key = `${accountId}:${path}:${statusCode}`;
const next = (feishuWebhookStatusCounters.get(key) ?? 0) + 1;
feishuWebhookStatusCounters.set(key, next);
const next = feishuWebhookStatusCounters.increment(key);
if (next === 1 || next % FEISHU_WEBHOOK_COUNTER_LOG_EVERY === 0) {
const log = runtime?.log ?? console.log;
log(`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${next}`);

View File

@@ -6,7 +6,10 @@ import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
clearNostrProfileRateLimitStateForTest,
createNostrProfileHttpHandler,
getNostrProfileRateLimitStateSizeForTest,
isNostrProfileRateLimitedForTest,
type NostrProfileHttpContext,
} from "./nostr-profile-http.js";
@@ -136,6 +139,7 @@ function mockSuccessfulProfileImport() {
describe("nostr-profile-http", () => {
beforeEach(() => {
vi.clearAllMocks();
clearNostrProfileRateLimitStateForTest();
});
describe("route matching", () => {
@@ -358,6 +362,25 @@ describe("nostr-profile-http", () => {
}
}
});
it("caps tracked rate-limit keys to prevent unbounded growth", () => {
const now = 1_000_000;
for (let i = 0; i < 2_500; i += 1) {
isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
}
expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
});
it("prunes stale rate-limit keys after the window elapses", () => {
const now = 2_000_000;
for (let i = 0; i < 100; i += 1) {
isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
}
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
isNostrProfileRateLimitedForTest("fresh", now + 60_001);
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
});
});
describe("POST /api/channels/nostr/:accountId/profile/import", () => {

View File

@@ -9,6 +9,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
createFixedWindowRateLimiter,
isBlockedHostnameOrIp,
readJsonBodyWithLimit,
requestBodyErrorToText,
@@ -41,30 +42,29 @@ export interface NostrProfileHttpContext {
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number;
windowStart: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
const profileRateLimiter = createFixedWindowRateLimiter({
windowMs: RATE_LIMIT_WINDOW_MS,
maxRequests: RATE_LIMIT_MAX_REQUESTS,
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
});
export function clearNostrProfileRateLimitStateForTest(): void {
profileRateLimiter.clear();
}
export function getNostrProfileRateLimitStateSizeForTest(): number {
return profileRateLimiter.size();
}
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
return profileRateLimiter.isRateLimited(accountId, nowMs);
}
function checkRateLimit(accountId: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(accountId);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(accountId, { count: 1, windowStart: now });
return true;
}
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
return false;
}
entry.count++;
return true;
return !profileRateLimiter.isRateLimited(accountId);
}
// ============================================================================

View File

@@ -134,4 +134,13 @@ describe("RateLimiter", () => {
// user2 should still be allowed
expect(limiter.check("user2")).toBe(true);
});
it("caps tracked users to prevent unbounded growth", () => {
const limiter = new RateLimiter(1, 60, 3);
expect(limiter.check("user1")).toBe(true);
expect(limiter.check("user2")).toBe(true);
expect(limiter.check("user3")).toBe(true);
expect(limiter.check("user4")).toBe(true);
expect(limiter.size()).toBeLessThanOrEqual(3);
});
});

View File

@@ -3,6 +3,7 @@
*/
import * as crypto from "node:crypto";
import { createFixedWindowRateLimiter, type FixedWindowRateLimiter } from "openclaw/plugin-sdk";
export type DmAuthorizationResult =
| { allowed: true }
@@ -86,55 +87,35 @@ export function sanitizeInput(text: string): string {
* Sliding window rate limiter per user ID.
*/
export class RateLimiter {
private requests: Map<string, number[]> = new Map();
private limit: number;
private windowMs: number;
private lastCleanup = 0;
private cleanupIntervalMs: number;
private readonly limiter: FixedWindowRateLimiter;
private readonly limit: number;
constructor(limit = 30, windowSeconds = 60) {
constructor(limit = 30, windowSeconds = 60, maxTrackedUsers = 5_000) {
this.limit = limit;
this.windowMs = windowSeconds * 1000;
this.cleanupIntervalMs = this.windowMs * 5; // cleanup every 5 windows
this.limiter = createFixedWindowRateLimiter({
windowMs: Math.max(1, Math.floor(windowSeconds * 1000)),
maxRequests: Math.max(1, Math.floor(limit)),
maxTrackedKeys: Math.max(1, Math.floor(maxTrackedUsers)),
});
}
/** Returns true if the request is allowed, false if rate-limited. */
check(userId: string): boolean {
const now = Date.now();
const windowStart = now - this.windowMs;
// Periodic cleanup of stale entries to prevent memory leak
if (now - this.lastCleanup > this.cleanupIntervalMs) {
this.cleanup(windowStart);
this.lastCleanup = now;
}
let timestamps = this.requests.get(userId);
if (timestamps) {
timestamps = timestamps.filter((ts) => ts > windowStart);
} else {
timestamps = [];
}
if (timestamps.length >= this.limit) {
this.requests.set(userId, timestamps);
return false;
}
timestamps.push(now);
this.requests.set(userId, timestamps);
return true;
return !this.limiter.isRateLimited(userId);
}
/** Remove entries with no recent activity. */
private cleanup(windowStart: number): void {
for (const [userId, timestamps] of this.requests) {
const active = timestamps.filter((ts) => ts > windowStart);
if (active.length === 0) {
this.requests.delete(userId);
} else {
this.requests.set(userId, active);
}
}
/** Exposed for tests and diagnostics. */
size(): number {
return this.limiter.size();
}
/** Exposed for tests and account lifecycle cleanup. */
clear(): void {
this.limiter.clear();
}
/** Exposed for tests. */
maxRequests(): number {
return this.limit;
}
}

View File

@@ -2,7 +2,10 @@ import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ResolvedSynologyChatAccount } from "./types.js";
import { createWebhookHandler } from "./webhook-handler.js";
import {
clearSynologyWebhookRateLimiterStateForTest,
createWebhookHandler,
} from "./webhook-handler.js";
// Mock sendMessage to prevent real HTTP calls
vi.mock("./client.js", () => ({
@@ -73,6 +76,7 @@ describe("createWebhookHandler", () => {
let log: { info: any; warn: any; error: any };
beforeEach(() => {
clearSynologyWebhookRateLimiterStateForTest();
log = {
info: vi.fn(),
warn: vi.fn(),

View File

@@ -14,13 +14,25 @@ const rateLimiters = new Map<string, RateLimiter>();
function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
let rl = rateLimiters.get(account.accountId);
if (!rl) {
if (!rl || rl.maxRequests() !== account.rateLimitPerMinute) {
rl?.clear();
rl = new RateLimiter(account.rateLimitPerMinute);
rateLimiters.set(account.accountId, rl);
}
return rl;
}
export function clearSynologyWebhookRateLimiterStateForTest(): void {
for (const limiter of rateLimiters.values()) {
limiter.clear();
}
rateLimiters.clear();
}
export function getSynologyWebhookRateLimiterCountForTest(): number {
return rateLimiters.size;
}
/** Read the full request body as a string. */
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {

View File

@@ -28,6 +28,9 @@ import {
resolveZaloRuntimeGroupPolicy,
} from "./group-access.js";
import {
clearZaloWebhookSecurityStateForTest,
getZaloWebhookRateLimitStateSizeForTest,
getZaloWebhookStatusCounterSizeForTest,
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
type ZaloWebhookTarget,
@@ -73,6 +76,12 @@ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void
return registerZaloWebhookTargetInternal(target);
}
export {
clearZaloWebhookSecurityStateForTest,
getZaloWebhookRateLimitStateSizeForTest,
getZaloWebhookStatusCounterSizeForTest,
};
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,

View File

@@ -1,8 +1,14 @@
import { createServer, type RequestListener } from "node:http";
import type { AddressInfo } from "node:net";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearZaloWebhookSecurityStateForTest,
getZaloWebhookRateLimitStateSizeForTest,
getZaloWebhookStatusCounterSizeForTest,
handleZaloWebhookRequest,
registerZaloWebhookTarget,
} from "./monitor.js";
import type { ResolvedZaloAccount } from "./types.js";
async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
@@ -56,6 +62,10 @@ function registerTarget(params: {
}
describe("handleZaloWebhookRequest", () => {
afterEach(() => {
clearZaloWebhookSecurityStateForTest();
});
it("returns 400 for non-object payloads", async () => {
const unregister = registerTarget({ path: "/hook" });
@@ -196,4 +206,57 @@ describe("handleZaloWebhookRequest", () => {
unregister();
}
});
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
const unregister = registerTarget({ path: "/hook-query-status" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
for (let i = 0; i < 200; i += 1) {
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "invalid-token",
"content-type": "application/json",
},
body: "{}",
});
expect(response.status).toBe(401);
}
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
});
} finally {
unregister();
}
});
it("rate limits authenticated requests even when query strings churn", async () => {
const unregister = registerTarget({ path: "/hook-query-rate" });
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
}
expect(saw429).toBe(true);
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
});
} finally {
unregister();
}
});
});

View File

@@ -2,7 +2,9 @@ import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createBoundedCounter,
createDedupeCache,
createFixedWindowRateLimiter,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
@@ -14,12 +16,13 @@ import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.js";
type WebhookRateLimitState = { count: number; windowStartMs: number };
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
const ZALO_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS = 4_096;
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
const ZALO_WEBHOOK_COUNTER_MAX_TRACKED_KEYS = 4_096;
const ZALO_WEBHOOK_COUNTER_TTL_MS = 6 * 60 * 60_000;
export type ZaloWebhookTarget = {
token: string;
@@ -40,12 +43,32 @@ export type ZaloWebhookProcessUpdate = (params: {
}) => Promise<void>;
const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
const webhookRateLimits = new Map<string, WebhookRateLimitState>();
const webhookRateLimiter = createFixedWindowRateLimiter({
windowMs: ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS,
maxRequests: ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS,
maxTrackedKeys: ZALO_WEBHOOK_RATE_LIMIT_MAX_TRACKED_KEYS,
});
const recentWebhookEvents = createDedupeCache({
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
maxSize: 5000,
});
const webhookStatusCounters = new Map<string, number>();
const webhookStatusCounters = createBoundedCounter({
maxTrackedKeys: ZALO_WEBHOOK_COUNTER_MAX_TRACKED_KEYS,
ttlMs: ZALO_WEBHOOK_COUNTER_TTL_MS,
});
export function clearZaloWebhookSecurityStateForTest(): void {
webhookRateLimiter.clear();
webhookStatusCounters.clear();
}
export function getZaloWebhookRateLimitStateSizeForTest(): number {
return webhookRateLimiter.size();
}
export function getZaloWebhookStatusCounterSizeForTest(): number {
return webhookStatusCounters.size();
}
function isJsonContentType(value: string | string[] | undefined): boolean {
const first = Array.isArray(value) ? value[0] : value;
@@ -73,20 +96,6 @@ function timingSafeEquals(left: string, right: string): boolean {
return timingSafeEqual(leftBuffer, rightBuffer);
}
function isWebhookRateLimited(key: string, nowMs: number): boolean {
const state = webhookRateLimits.get(key);
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
return false;
}
state.count += 1;
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
return true;
}
return false;
}
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
const messageId = update.message?.message_id;
if (!messageId) {
@@ -105,8 +114,7 @@ function recordWebhookStatus(
return;
}
const key = `${path}:${statusCode}`;
const next = (webhookStatusCounters.get(key) ?? 0) + 1;
webhookStatusCounters.set(key, next);
const next = webhookStatusCounters.increment(key);
if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
runtime?.log?.(
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
@@ -127,7 +135,7 @@ export async function handleZaloWebhookRequest(
if (!resolved) {
return false;
}
const { targets } = resolved;
const { targets, path } = resolved;
if (rejectNonPostWebhookRequest(req, res)) {
return true;
@@ -140,21 +148,20 @@ export async function handleZaloWebhookRequest(
if (matchedTarget.kind === "none") {
res.statusCode = 401;
res.end("unauthorized");
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
return true;
}
if (matchedTarget.kind === "ambiguous") {
res.statusCode = 401;
res.end("ambiguous webhook target");
recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
return true;
}
const target = matchedTarget.target;
const path = req.url ?? "<unknown>";
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
const nowMs = Date.now();
if (isWebhookRateLimited(rateLimitKey, nowMs)) {
if (webhookRateLimiter.isRateLimited(rateLimitKey, nowMs)) {
res.statusCode = 429;
res.end("Too Many Requests");
recordWebhookStatus(target.runtime, path, res.statusCode);

View File

@@ -297,6 +297,8 @@ export {
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "../infra/http-body.js";
export { createBoundedCounter, createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
export type { BoundedCounter, FixedWindowRateLimiter } from "./webhook-memory-guards.js";
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
export {

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { createBoundedCounter, createFixedWindowRateLimiter } from "./webhook-memory-guards.js";
describe("createFixedWindowRateLimiter", () => {
it("enforces a fixed-window request limit", () => {
const limiter = createFixedWindowRateLimiter({
windowMs: 60_000,
maxRequests: 3,
maxTrackedKeys: 100,
});
expect(limiter.isRateLimited("k", 1_000)).toBe(false);
expect(limiter.isRateLimited("k", 1_001)).toBe(false);
expect(limiter.isRateLimited("k", 1_002)).toBe(false);
expect(limiter.isRateLimited("k", 1_003)).toBe(true);
});
it("resets counters after the window elapses", () => {
const limiter = createFixedWindowRateLimiter({
windowMs: 10,
maxRequests: 1,
maxTrackedKeys: 100,
});
expect(limiter.isRateLimited("k", 100)).toBe(false);
expect(limiter.isRateLimited("k", 101)).toBe(true);
expect(limiter.isRateLimited("k", 111)).toBe(false);
});
it("caps tracked keys", () => {
const limiter = createFixedWindowRateLimiter({
windowMs: 60_000,
maxRequests: 10,
maxTrackedKeys: 5,
});
for (let i = 0; i < 20; i += 1) {
limiter.isRateLimited(`key-${i}`, 1_000 + i);
}
expect(limiter.size()).toBeLessThanOrEqual(5);
});
it("prunes stale keys", () => {
const limiter = createFixedWindowRateLimiter({
windowMs: 10,
maxRequests: 10,
maxTrackedKeys: 100,
pruneIntervalMs: 10,
});
for (let i = 0; i < 20; i += 1) {
limiter.isRateLimited(`key-${i}`, 100);
}
expect(limiter.size()).toBe(20);
limiter.isRateLimited("fresh", 120);
expect(limiter.size()).toBe(1);
});
});
describe("createBoundedCounter", () => {
it("increments and returns per-key counts", () => {
const counter = createBoundedCounter({ maxTrackedKeys: 100 });
expect(counter.increment("k", 1_000)).toBe(1);
expect(counter.increment("k", 1_001)).toBe(2);
expect(counter.increment("k", 1_002)).toBe(3);
});
it("caps tracked keys", () => {
const counter = createBoundedCounter({ maxTrackedKeys: 3 });
for (let i = 0; i < 10; i += 1) {
counter.increment(`k-${i}`, 1_000 + i);
}
expect(counter.size()).toBeLessThanOrEqual(3);
});
it("expires stale keys when ttl is set", () => {
const counter = createBoundedCounter({
maxTrackedKeys: 100,
ttlMs: 10,
pruneIntervalMs: 10,
});
counter.increment("old-1", 100);
counter.increment("old-2", 100);
expect(counter.size()).toBe(2);
counter.increment("fresh", 120);
expect(counter.size()).toBe(1);
});
});

View File

@@ -0,0 +1,136 @@
import { pruneMapToMaxSize } from "../infra/map-size.js";
type FixedWindowState = {
count: number;
windowStartMs: number;
};
type CounterState = {
count: number;
updatedAtMs: number;
};
export type FixedWindowRateLimiter = {
isRateLimited: (key: string, nowMs?: number) => boolean;
size: () => number;
clear: () => void;
};
export type BoundedCounter = {
increment: (key: string, nowMs?: number) => number;
size: () => number;
clear: () => void;
};
export function createFixedWindowRateLimiter(options: {
windowMs: number;
maxRequests: number;
maxTrackedKeys: number;
pruneIntervalMs?: number;
}): FixedWindowRateLimiter {
const windowMs = Math.max(1, Math.floor(options.windowMs));
const maxRequests = Math.max(1, Math.floor(options.maxRequests));
const maxTrackedKeys = Math.max(1, Math.floor(options.maxTrackedKeys));
const pruneIntervalMs = Math.max(1, Math.floor(options.pruneIntervalMs ?? windowMs));
const state = new Map<string, FixedWindowState>();
let lastPruneMs = 0;
const touch = (key: string, value: FixedWindowState) => {
state.delete(key);
state.set(key, value);
};
const prune = (nowMs: number) => {
for (const [key, entry] of state) {
if (nowMs - entry.windowStartMs >= windowMs) {
state.delete(key);
}
}
};
return {
isRateLimited: (key: string, nowMs = Date.now()) => {
if (!key) {
return false;
}
if (nowMs - lastPruneMs >= pruneIntervalMs) {
prune(nowMs);
lastPruneMs = nowMs;
}
const existing = state.get(key);
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
touch(key, { count: 1, windowStartMs: nowMs });
pruneMapToMaxSize(state, maxTrackedKeys);
return false;
}
const nextCount = existing.count + 1;
touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
pruneMapToMaxSize(state, maxTrackedKeys);
return nextCount > maxRequests;
},
size: () => state.size,
clear: () => {
state.clear();
lastPruneMs = 0;
},
};
}
export function createBoundedCounter(options: {
maxTrackedKeys: number;
ttlMs?: number;
pruneIntervalMs?: number;
}): BoundedCounter {
const maxTrackedKeys = Math.max(1, Math.floor(options.maxTrackedKeys));
const ttlMs = Math.max(0, Math.floor(options.ttlMs ?? 0));
const pruneIntervalMs = Math.max(
1,
Math.floor(options.pruneIntervalMs ?? (ttlMs > 0 ? ttlMs : 60_000)),
);
const counters = new Map<string, CounterState>();
let lastPruneMs = 0;
const touch = (key: string, value: CounterState) => {
counters.delete(key);
counters.set(key, value);
};
const isExpired = (entry: CounterState, nowMs: number) =>
ttlMs > 0 && nowMs - entry.updatedAtMs >= ttlMs;
const prune = (nowMs: number) => {
if (ttlMs > 0) {
for (const [key, entry] of counters) {
if (isExpired(entry, nowMs)) {
counters.delete(key);
}
}
}
};
return {
increment: (key: string, nowMs = Date.now()) => {
if (!key) {
return 0;
}
if (nowMs - lastPruneMs >= pruneIntervalMs) {
prune(nowMs);
lastPruneMs = nowMs;
}
const existing = counters.get(key);
const baseCount = existing && !isExpired(existing, nowMs) ? existing.count : 0;
const nextCount = baseCount + 1;
touch(key, { count: nextCount, updatedAtMs: nowMs });
pruneMapToMaxSize(counters, maxTrackedKeys);
return nextCount;
},
size: () => counters.size,
clear: () => {
counters.clear();
lastPruneMs = 0;
},
};
}