From 3a68c56264bb42677c6bd38f487f3bc7d6c573bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 00:31:31 +0000 Subject: [PATCH] refactor(security): unify webhook guardrails across channels --- extensions/feishu/src/monitor.ts | 74 ++++---- extensions/zalo/src/monitor.webhook.ts | 103 +++++------ src/plugin-sdk/index.ts | 20 ++- src/plugin-sdk/webhook-memory-guards.test.ts | 60 ++++++- src/plugin-sdk/webhook-memory-guards.ts | 60 +++++++ src/plugin-sdk/webhook-request-guards.test.ts | 160 ++++++++++++++++++ src/plugin-sdk/webhook-request-guards.ts | 81 +++++++++ 7 files changed, 450 insertions(+), 108 deletions(-) create mode 100644 src/plugin-sdk/webhook-request-guards.test.ts create mode 100644 src/plugin-sdk/webhook-request-guards.ts diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index 5a7e777cd1f..0d532a31726 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -2,12 +2,15 @@ import * as crypto from "crypto"; import * as http from "http"; import * as Lark from "@larksuiteoapi/node-sdk"; import { + applyBasicWebhookRequestGuards, type ClawdbotConfig, - createBoundedCounter, createFixedWindowRateLimiter, + createWebhookAnomalyTracker, type RuntimeEnv, type HistoryEntry, installRequestBodyLimitGuard, + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_RATE_LIMIT_DEFAULTS, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; @@ -30,12 +33,6 @@ const httpServers = new Map(); const botOpenIds = new Map(); const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000; -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 = { @@ -60,27 +57,19 @@ type ResolveReactionSyntheticEventParams = { }; 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, + windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, + maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys, }); -const feishuWebhookStatusCounters = createBoundedCounter({ - maxTrackedKeys: FEISHU_WEBHOOK_COUNTER_MAX_TRACKED_KEYS, - ttlMs: FEISHU_WEBHOOK_COUNTER_TTL_MS, +const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({ + maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys, + ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs, + logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery, }); -function isJsonContentType(value: string | string[] | undefined): boolean { - const first = Array.isArray(value) ? value[0] : value; - if (!first) { - return false; - } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); - return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); -} - export function clearFeishuWebhookRateLimitStateForTest(): void { feishuWebhookRateLimiter.clear(); - feishuWebhookStatusCounters.clear(); + feishuWebhookAnomalyTracker.clear(); } export function getFeishuWebhookRateLimitStateSizeForTest(): number { @@ -91,25 +80,19 @@ export function isWebhookRateLimitedForTest(key: string, nowMs: number): boolean return feishuWebhookRateLimiter.isRateLimited(key, nowMs); } -function isWebhookRateLimited(key: string, nowMs: number): boolean { - return isWebhookRateLimitedForTest(key, nowMs); -} - function recordWebhookStatus( runtime: RuntimeEnv | undefined, accountId: string, path: string, statusCode: number, ): void { - if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { - return; - } - const key = `${accountId}:${path}:${statusCode}`; - 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}`); - } + feishuWebhookAnomalyTracker.record({ + key: `${accountId}:${path}:${statusCode}`, + statusCode, + log: runtime?.log ?? console.log, + message: (count) => + `feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${count}`, + }); } async function withTimeout(promise: Promise, timeoutMs: number): Promise { @@ -462,15 +445,16 @@ async function monitorWebhook({ }); const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`; - if (isWebhookRateLimited(rateLimitKey, Date.now())) { - res.statusCode = 429; - res.end("Too Many Requests"); - return; - } - - if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) { - res.statusCode = 415; - res.end("Unsupported Media Type"); + if ( + !applyBasicWebhookRequestGuards({ + req, + res, + rateLimiter: feishuWebhookRateLimiter, + rateLimitKey, + nowMs: Date.now(), + requireJsonContentType: true, + }) + ) { return; } diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index 9957d116a5c..8214e388427 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -2,27 +2,22 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { - createBoundedCounter, createDedupeCache, createFixedWindowRateLimiter, - readJsonBodyWithLimit, + createWebhookAnomalyTracker, + readJsonWebhookBodyOrReject, + applyBasicWebhookRequestGuards, registerWebhookTarget, - rejectNonPostWebhookRequest, - requestBodyErrorToText, resolveSingleWebhookTarget, resolveWebhookTargets, + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_RATE_LIMIT_DEFAULTS, } from "openclaw/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import type { ZaloFetch, ZaloUpdate } from "./api.js"; import type { ZaloRuntimeEnv } from "./monitor.js"; -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; @@ -44,22 +39,23 @@ export type ZaloWebhookProcessUpdate = (params: { const webhookTargets = new Map(); 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, + windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, + maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, + maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys, }); const recentWebhookEvents = createDedupeCache({ ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS, maxSize: 5000, }); -const webhookStatusCounters = createBoundedCounter({ - maxTrackedKeys: ZALO_WEBHOOK_COUNTER_MAX_TRACKED_KEYS, - ttlMs: ZALO_WEBHOOK_COUNTER_TTL_MS, +const webhookAnomalyTracker = createWebhookAnomalyTracker({ + maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys, + ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs, + logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery, }); export function clearZaloWebhookSecurityStateForTest(): void { webhookRateLimiter.clear(); - webhookStatusCounters.clear(); + webhookAnomalyTracker.clear(); } export function getZaloWebhookRateLimitStateSizeForTest(): number { @@ -67,16 +63,7 @@ export function getZaloWebhookRateLimitStateSizeForTest(): number { } export function getZaloWebhookStatusCounterSizeForTest(): number { - return webhookStatusCounters.size(); -} - -function isJsonContentType(value: string | string[] | undefined): boolean { - const first = Array.isArray(value) ? value[0] : value; - if (!first) { - return false; - } - const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); - return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); + return webhookAnomalyTracker.size(); } function timingSafeEquals(left: string, right: string): boolean { @@ -110,16 +97,13 @@ function recordWebhookStatus( path: string, statusCode: number, ): void { - if (![400, 401, 408, 413, 415, 429].includes(statusCode)) { - return; - } - const key = `${path}:${statusCode}`; - 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)}`, - ); - } + webhookAnomalyTracker.record({ + key: `${path}:${statusCode}`, + statusCode, + log: runtime?.log, + message: (count) => + `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(count)}`, + }); } export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void { @@ -137,7 +121,13 @@ export async function handleZaloWebhookRequest( } const { targets, path } = resolved; - if (rejectNonPostWebhookRequest(req, res)) { + if ( + !applyBasicWebhookRequestGuards({ + req, + res, + allowMethods: ["POST"], + }) + ) { return true; } @@ -161,41 +151,34 @@ export async function handleZaloWebhookRequest( const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`; const nowMs = Date.now(); - if (webhookRateLimiter.isRateLimited(rateLimitKey, nowMs)) { - res.statusCode = 429; - res.end("Too Many Requests"); + if ( + !applyBasicWebhookRequestGuards({ + req, + res, + rateLimiter: webhookRateLimiter, + rateLimitKey, + nowMs, + requireJsonContentType: true, + }) + ) { recordWebhookStatus(target.runtime, path, res.statusCode); return true; } - - if (!isJsonContentType(req.headers["content-type"])) { - res.statusCode = 415; - res.end("Unsupported Media Type"); - recordWebhookStatus(target.runtime, path, res.statusCode); - return true; - } - - const body = await readJsonBodyWithLimit(req, { + const body = await readJsonWebhookBodyOrReject({ + req, + res, maxBytes: 1024 * 1024, timeoutMs: 30_000, emptyObjectOnEmpty: false, + invalidJsonMessage: "Bad Request", }); if (!body.ok) { - res.statusCode = - body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; - const message = - body.code === "PAYLOAD_TOO_LARGE" - ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") - : body.code === "REQUEST_BODY_TIMEOUT" - ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") - : "Bad Request"; - res.end(message); recordWebhookStatus(target.runtime, path, res.statusCode); return true; } + const raw = body.value; // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }. - const raw = body.value; const record = raw && typeof raw === "object" ? (raw as Record) : null; const update: ZaloUpdate | undefined = record && record.ok === true && record.result diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index cf6745fa6eb..dde00254bab 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -129,6 +129,11 @@ export { resolveWebhookTargets, } from "./webhook-targets.js"; export type { WebhookTargetMatchResult } from "./webhook-targets.js"; +export { + applyBasicWebhookRequestGuards, + isJsonContentType, + readJsonWebhookBodyOrReject, +} from "./webhook-request-guards.js"; export type { AgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { @@ -297,8 +302,19 @@ 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 { + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_ANOMALY_STATUS_CODES, + WEBHOOK_RATE_LIMIT_DEFAULTS, + createBoundedCounter, + createFixedWindowRateLimiter, + createWebhookAnomalyTracker, +} from "./webhook-memory-guards.js"; +export type { + BoundedCounter, + FixedWindowRateLimiter, + WebhookAnomalyTracker, +} from "./webhook-memory-guards.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { diff --git a/src/plugin-sdk/webhook-memory-guards.test.ts b/src/plugin-sdk/webhook-memory-guards.test.ts index 296cc6fc327..a4e639179de 100644 --- a/src/plugin-sdk/webhook-memory-guards.test.ts +++ b/src/plugin-sdk/webhook-memory-guards.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { createBoundedCounter, createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +import { + createBoundedCounter, + createFixedWindowRateLimiter, + createWebhookAnomalyTracker, + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_RATE_LIMIT_DEFAULTS, +} from "./webhook-memory-guards.js"; describe("createFixedWindowRateLimiter", () => { it("enforces a fixed-window request limit", () => { @@ -93,3 +99,55 @@ describe("createBoundedCounter", () => { expect(counter.size()).toBe(1); }); }); + +describe("defaults", () => { + it("exports shared webhook limit profiles", () => { + expect(WEBHOOK_RATE_LIMIT_DEFAULTS).toEqual({ + windowMs: 60_000, + maxRequests: 120, + maxTrackedKeys: 4_096, + }); + expect(WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys).toBe(4_096); + expect(WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs).toBe(21_600_000); + expect(WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery).toBe(25); + }); +}); + +describe("createWebhookAnomalyTracker", () => { + it("increments only tracked status codes and logs at configured cadence", () => { + const logs: string[] = []; + const tracker = createWebhookAnomalyTracker({ + trackedStatusCodes: [401], + logEvery: 2, + }); + + expect( + tracker.record({ + key: "k", + statusCode: 415, + message: (count) => `ignored:${count}`, + log: (msg) => logs.push(msg), + }), + ).toBe(0); + + expect( + tracker.record({ + key: "k", + statusCode: 401, + message: (count) => `hit:${count}`, + log: (msg) => logs.push(msg), + }), + ).toBe(1); + + expect( + tracker.record({ + key: "k", + statusCode: 401, + message: (count) => `hit:${count}`, + log: (msg) => logs.push(msg), + }), + ).toBe(2); + + expect(logs).toEqual(["hit:1", "hit:2"]); + }); +}); diff --git a/src/plugin-sdk/webhook-memory-guards.ts b/src/plugin-sdk/webhook-memory-guards.ts index 5f976895184..50a43c0b3ab 100644 --- a/src/plugin-sdk/webhook-memory-guards.ts +++ b/src/plugin-sdk/webhook-memory-guards.ts @@ -22,6 +22,32 @@ export type BoundedCounter = { clear: () => void; }; +export const WEBHOOK_RATE_LIMIT_DEFAULTS = Object.freeze({ + windowMs: 60_000, + maxRequests: 120, + maxTrackedKeys: 4_096, +}); + +export const WEBHOOK_ANOMALY_COUNTER_DEFAULTS = Object.freeze({ + maxTrackedKeys: 4_096, + ttlMs: 6 * 60 * 60_000, + logEvery: 25, +}); + +export const WEBHOOK_ANOMALY_STATUS_CODES = Object.freeze([400, 401, 408, 413, 415, 429]); + +export type WebhookAnomalyTracker = { + record: (params: { + key: string; + statusCode: number; + message: (count: number) => string; + log?: (message: string) => void; + nowMs?: number; + }) => number; + size: () => number; + clear: () => void; +}; + export function createFixedWindowRateLimiter(options: { windowMs: number; maxRequests: number; @@ -134,3 +160,37 @@ export function createBoundedCounter(options: { }, }; } + +export function createWebhookAnomalyTracker(options?: { + maxTrackedKeys?: number; + ttlMs?: number; + logEvery?: number; + trackedStatusCodes?: readonly number[]; +}): WebhookAnomalyTracker { + const maxTrackedKeys = Math.max( + 1, + Math.floor(options?.maxTrackedKeys ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys), + ); + const ttlMs = Math.max(0, Math.floor(options?.ttlMs ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs)); + const logEvery = Math.max( + 1, + Math.floor(options?.logEvery ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery), + ); + 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(message(next)); + } + return next; + }, + size: () => counter.size(), + clear: () => counter.clear(), + }; +} diff --git a/src/plugin-sdk/webhook-request-guards.test.ts b/src/plugin-sdk/webhook-request-guards.test.ts new file mode 100644 index 00000000000..90b492c657a --- /dev/null +++ b/src/plugin-sdk/webhook-request-guards.test.ts @@ -0,0 +1,160 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage } from "node:http"; +import { describe, expect, it } from "vitest"; +import { createMockServerResponse } from "../test-utils/mock-http-response.js"; +import { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +import { + applyBasicWebhookRequestGuards, + isJsonContentType, + readJsonWebhookBodyOrReject, +} from "./webhook-request-guards.js"; + +type MockIncomingMessage = IncomingMessage & { + destroyed?: boolean; + destroy: () => MockIncomingMessage; +}; + +function createMockRequest(params: { + method?: string; + headers?: Record; + chunks?: string[]; + emitEnd?: boolean; +}): MockIncomingMessage { + const req = new EventEmitter() as MockIncomingMessage; + req.method = params.method ?? "POST"; + req.headers = params.headers ?? {}; + req.destroyed = false; + req.destroy = (() => { + req.destroyed = true; + return req; + }) as MockIncomingMessage["destroy"]; + + if (params.chunks) { + void Promise.resolve().then(() => { + for (const chunk of params.chunks ?? []) { + req.emit("data", Buffer.from(chunk, "utf-8")); + } + if (params.emitEnd !== false) { + req.emit("end"); + } + }); + } + + return req; +} + +describe("isJsonContentType", () => { + it("accepts application/json and +json suffixes", () => { + expect(isJsonContentType("application/json")).toBe(true); + expect(isJsonContentType("application/cloudevents+json; charset=utf-8")).toBe(true); + }); + + it("rejects non-json media types", () => { + expect(isJsonContentType("text/plain")).toBe(false); + expect(isJsonContentType(undefined)).toBe(false); + }); +}); + +describe("applyBasicWebhookRequestGuards", () => { + it("rejects disallowed HTTP methods", () => { + const req = createMockRequest({ method: "GET" }); + const res = createMockServerResponse(); + const ok = applyBasicWebhookRequestGuards({ + req, + res, + allowMethods: ["POST"], + }); + expect(ok).toBe(false); + expect(res.statusCode).toBe(405); + expect(res.getHeader("allow")).toBe("POST"); + }); + + it("enforces rate limits", () => { + const limiter = createFixedWindowRateLimiter({ + windowMs: 60_000, + maxRequests: 1, + maxTrackedKeys: 10, + }); + const req1 = createMockRequest({ method: "POST" }); + const res1 = createMockServerResponse(); + const req2 = createMockRequest({ method: "POST" }); + const res2 = createMockServerResponse(); + expect( + applyBasicWebhookRequestGuards({ + req: req1, + res: res1, + rateLimiter: limiter, + rateLimitKey: "k", + nowMs: 1_000, + }), + ).toBe(true); + expect( + applyBasicWebhookRequestGuards({ + req: req2, + res: res2, + rateLimiter: limiter, + rateLimitKey: "k", + nowMs: 1_001, + }), + ).toBe(false); + expect(res2.statusCode).toBe(429); + }); + + it("rejects non-json requests when required", () => { + const req = createMockRequest({ + method: "POST", + headers: { "content-type": "text/plain" }, + }); + const res = createMockServerResponse(); + const ok = applyBasicWebhookRequestGuards({ + req, + res, + requireJsonContentType: true, + }); + expect(ok).toBe(false); + expect(res.statusCode).toBe(415); + }); +}); + +describe("readJsonWebhookBodyOrReject", () => { + it("returns parsed JSON body", async () => { + const req = createMockRequest({ chunks: ['{"ok":true}'] }); + const res = createMockServerResponse(); + await expect( + readJsonWebhookBodyOrReject({ + req, + res, + maxBytes: 1024, + emptyObjectOnEmpty: false, + }), + ).resolves.toEqual({ ok: true, value: { ok: true } }); + }); + + it("preserves valid JSON null payload", async () => { + const req = createMockRequest({ chunks: ["null"] }); + const res = createMockServerResponse(); + await expect( + readJsonWebhookBodyOrReject({ + req, + res, + maxBytes: 1024, + emptyObjectOnEmpty: false, + }), + ).resolves.toEqual({ ok: true, value: null }); + }); + + it("writes 400 on invalid JSON payload", async () => { + const req = createMockRequest({ chunks: ["{bad json"] }); + const res = createMockServerResponse(); + await expect( + readJsonWebhookBodyOrReject({ + req, + res, + maxBytes: 1024, + emptyObjectOnEmpty: false, + }), + ).resolves.toEqual({ ok: false }); + expect(res.statusCode).toBe(400); + expect(res.body).toBe("Bad Request"); + }); +}); diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts new file mode 100644 index 00000000000..956ec09c2cf --- /dev/null +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -0,0 +1,81 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; +import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; + +export function isJsonContentType(value: string | string[] | undefined): boolean { + const first = Array.isArray(value) ? value[0] : value; + if (!first) { + return false; + } + const mediaType = first.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json")); +} + +export function applyBasicWebhookRequestGuards(params: { + req: IncomingMessage; + res: ServerResponse; + allowMethods?: readonly string[]; + rateLimiter?: FixedWindowRateLimiter; + rateLimitKey?: string; + nowMs?: number; + requireJsonContentType?: boolean; +}): boolean { + const allowMethods = params.allowMethods?.length ? params.allowMethods : null; + if (allowMethods && !allowMethods.includes(params.req.method ?? "")) { + params.res.statusCode = 405; + params.res.setHeader("Allow", allowMethods.join(", ")); + params.res.end("Method Not Allowed"); + return false; + } + + if ( + params.rateLimiter && + params.rateLimitKey && + params.rateLimiter.isRateLimited(params.rateLimitKey, params.nowMs ?? Date.now()) + ) { + params.res.statusCode = 429; + params.res.end("Too Many Requests"); + return false; + } + + if ( + params.requireJsonContentType && + params.req.method === "POST" && + !isJsonContentType(params.req.headers["content-type"]) + ) { + params.res.statusCode = 415; + params.res.end("Unsupported Media Type"); + return false; + } + + return true; +} + +export async function readJsonWebhookBodyOrReject(params: { + req: IncomingMessage; + res: ServerResponse; + maxBytes: number; + timeoutMs?: number; + emptyObjectOnEmpty?: boolean; + invalidJsonMessage?: string; +}): Promise<{ ok: true; value: unknown } | { ok: false }> { + const body = await readJsonBodyWithLimit(params.req, { + maxBytes: params.maxBytes, + timeoutMs: params.timeoutMs, + emptyObjectOnEmpty: params.emptyObjectOnEmpty, + }); + if (body.ok) { + return { ok: true, value: body.value }; + } + + params.res.statusCode = + body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; + const message = + body.code === "PAYLOAD_TOO_LARGE" + ? requestBodyErrorToText("PAYLOAD_TOO_LARGE") + : body.code === "REQUEST_BODY_TIMEOUT" + ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") + : (params.invalidJsonMessage ?? "Bad Request"); + params.res.end(message); + return { ok: false }; +}