Files
openclaw/src/plugin-sdk/webhook-memory-guards.ts
T
Peter Steinberger 964c8c84c1 refactor: consolidate coercion ownership (#122299)
* refactor: consolidate coercion ownership

Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries.

The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries.

* fix: guard integer option ownership

Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation.

* fix: keep integer helpers on numeric facade

Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract.

* fix: point numeric coercion to number runtime

Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
2026-08-11 17:14:53 -07:00

259 lines
8.5 KiB
TypeScript

// Webhook memory guards keep in-process webhook dedupe and replay state bounded.
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
import { pruneMapToMaxSize } from "../infra/map-size.js";
type FixedWindowState = {
count: number;
windowStartMs: number;
};
type CounterState = {
count: number;
updatedAtMs: number;
};
/** In-memory fixed-window limiter used by webhook ingress handlers. */
export type FixedWindowRateLimiter = {
/** Return true once the key exceeds its allowed request count in the current window. */
isRateLimited: (key: string, nowMs?: number) => boolean;
/** Number of tracked keys currently retained in memory. */
size: () => number;
/** Drop all tracked keys and reset pruning state. */
clear: () => void;
};
/** Bounded keyed counter for sampled webhook anomaly tracking. */
export type BoundedCounter = {
/** Increment one key and return its current count, or zero for empty keys. */
increment: (key: string, nowMs?: number) => number;
/** Number of tracked keys currently retained in memory. */
size: () => number;
/** Drop all tracked keys and reset pruning state. */
clear: () => void;
};
/** Default webhook ingress rate-limit settings for plugin monitors. */
export const WEBHOOK_RATE_LIMIT_DEFAULTS = Object.freeze({
windowMs: 60_000,
maxRequests: 120,
maxTrackedKeys: 4_096,
});
/** Default cardinality and sampling settings for webhook anomaly counters. */
export const WEBHOOK_ANOMALY_COUNTER_DEFAULTS = Object.freeze({
maxTrackedKeys: 4_096,
ttlMs: 6 * 60 * 60_000,
logEvery: 25,
});
/** HTTP status codes counted as anomalous webhook request outcomes. */
export const WEBHOOK_ANOMALY_STATUS_CODES = Object.freeze([400, 401, 408, 413, 415, 429]);
/** Records repeated webhook failures and exposes bounded in-memory state controls. */
export type WebhookAnomalyTracker = {
/** Count one tracked status for a key; returns zero when the status/key is ignored. */
record: (params: {
/** Stable anomaly key, typically route plus sender or remote identity. */
key: string;
/** HTTP status to count when it is in the tracked status-code set. */
statusCode: number;
/** Build the sampled log message from the current key count. */
message: (count: number) => string;
/** Optional log sink invoked for the first hit and every sampled repeat. */
log?: (message: string) => void;
/** Clock override for deterministic tests. */
nowMs?: number;
}) => number;
/** Number of tracked anomaly keys currently retained in memory. */
size: () => number;
/** Drop all tracked anomaly keys and reset pruning state. */
clear: () => void;
};
/** Create a simple fixed-window rate limiter for in-memory webhook protection. */
export function createFixedWindowRateLimiter(options: {
/** Duration of one fixed window in milliseconds. */
windowMs: number;
/** Maximum accepted requests per key during one window. */
maxRequests: number;
/** Maximum number of keys retained before oldest entries are pruned. */
maxTrackedKeys: number;
/** Optional interval for expired-window pruning. Defaults to `windowMs`. */
pruneIntervalMs?: number;
}): FixedWindowRateLimiter {
const windowMs = resolveIntegerOption(options.windowMs, WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, {
min: 1,
});
const maxRequests = resolveIntegerOption(
options.maxRequests,
WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
{ min: 1 },
);
const maxTrackedKeys = resolveIntegerOption(
options.maxTrackedKeys,
WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
{ min: 1 },
);
const pruneIntervalMs = resolveIntegerOption(options.pruneIntervalMs, windowMs, {
min: 1,
});
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 });
// Bound key cardinality after accepting the new key so high-cardinality webhook traffic
// cannot grow this pre-auth limiter without limit.
pruneMapToMaxSize(state, maxTrackedKeys);
return false;
}
const nextCount = existing.count + 1;
touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
// Refreshing the key before pruning keeps active keys newer than stale one-off probes.
pruneMapToMaxSize(state, maxTrackedKeys);
return nextCount > maxRequests;
},
size: () => state.size,
clear: () => {
state.clear();
lastPruneMs = 0;
},
};
}
/** Count keyed events in memory with optional TTL pruning and bounded cardinality. */
export function createBoundedCounter(options: {
/** Maximum number of keys retained before oldest entries are pruned. */
maxTrackedKeys: number;
/** Optional key TTL in milliseconds; zero disables TTL expiry. */
ttlMs?: number;
/** Optional interval for TTL pruning. */
pruneIntervalMs?: number;
}): BoundedCounter {
const maxTrackedKeys = resolveIntegerOption(
options.maxTrackedKeys,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
{ min: 1 },
);
const ttlMs = resolveIntegerOption(options.ttlMs, 0, { min: 0 });
const pruneIntervalMs = resolveIntegerOption(
options.pruneIntervalMs,
ttlMs > 0 ? ttlMs : 60_000,
{ min: 1 },
);
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 });
// Counters are diagnostic only; prefer bounded memory over retaining every anomaly key.
pruneMapToMaxSize(counters, maxTrackedKeys);
return nextCount;
},
size: () => counters.size,
clear: () => {
counters.clear();
lastPruneMs = 0;
},
};
}
/** Track repeated webhook failures and emit sampled logs for suspicious request patterns. */
export function createWebhookAnomalyTracker(options?: {
/** Maximum number of anomaly keys retained before oldest entries are pruned. */
maxTrackedKeys?: number;
/** Key TTL in milliseconds; zero disables TTL expiry. */
ttlMs?: number;
/** Log every Nth repeat after the first hit. */
logEvery?: number;
/** HTTP status codes that should be counted as anomalies. */
trackedStatusCodes?: readonly number[];
}): WebhookAnomalyTracker {
const maxTrackedKeys = resolveIntegerOption(
options?.maxTrackedKeys,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
{ min: 1 },
);
const ttlMs = resolveIntegerOption(options?.ttlMs, WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs, {
min: 0,
});
const logEvery = resolveIntegerOption(
options?.logEvery,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
{ min: 1 },
);
const trackedStatusCodes = new Set(options?.trackedStatusCodes ?? WEBHOOK_ANOMALY_STATUS_CODES);
const counter = createBoundedCounter({ maxTrackedKeys, ttlMs });
return {
record: ({ key, statusCode, message, log, nowMs }) => {
if (!trackedStatusCodes.has(statusCode)) {
return 0;
}
const next = counter.increment(key, nowMs);
if (log && (next === 1 || next % logEvery === 0)) {
// Log the first hit for visibility, then sample repeated failures to avoid noisy bursts.
log(message(next));
}
return next;
},
size: () => counter.size(),
clear: () => counter.clear(),
};
}