mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 00:43:57 +00:00
fix(security): harden webhook memory guards across channels
This commit is contained in:
@@ -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 {
|
||||
|
||||
95
src/plugin-sdk/webhook-memory-guards.test.ts
Normal file
95
src/plugin-sdk/webhook-memory-guards.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
136
src/plugin-sdk/webhook-memory-guards.ts
Normal file
136
src/plugin-sdk/webhook-memory-guards.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user