From d3e8b17aa6432536806b4853edc7939d891d0f25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 17:20:46 +0000 Subject: [PATCH] fix: harden webhook auth-before-body handling --- CHANGELOG.md | 1 + docs/channels/bluebubbles.md | 1 + docs/channels/googlechat.md | 2 + docs/channels/line.md | 4 + extensions/bluebubbles/src/monitor.test.ts | 33 +- extensions/bluebubbles/src/monitor.ts | 297 +++++++++--------- extensions/googlechat/src/monitor.ts | 234 +++++++++----- .../src/monitor.webhook-routing.test.ts | 95 +++++- package.json | 3 +- scripts/check-webhook-auth-body-order.mjs | 54 ++++ src/line/webhook-node.test.ts | 25 ++ src/line/webhook-node.ts | 12 +- src/plugin-sdk/index.ts | 6 + src/plugin-sdk/webhook-request-guards.test.ts | 76 +++++ src/plugin-sdk/webhook-request-guards.ts | 197 +++++++++++- 15 files changed, 789 insertions(+), 251 deletions(-) create mode 100644 scripts/check-webhook-auth-body-order.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1623a5eaf2f..2a7c6b7ffd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting. - Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting. - Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting. +- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting. - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths. - Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale. - Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 8c8267498b7..8654bb9795d 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -48,6 +48,7 @@ Security note: - Always set a webhook password. - Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology. +- Password authentication is checked before reading/parsing full webhook bodies. ## Keeping Messages.app alive (VM / headless setups) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 8281d0fb0d2..09693589af7 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -139,6 +139,8 @@ Configure your tunnel's ingress rules to only route the webhook path: ## How it works 1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer ` header. + - OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present. + - Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget. 2. OpenClaw verifies the token against the configured `audienceType` + `audience`: - `audienceType: "app-url"` → audience is your HTTPS webhook URL. - `audienceType: "project-number"` → audience is the Cloud project number. diff --git a/docs/channels/line.md b/docs/channels/line.md index b87cbd3f5fb..50972d93d21 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events ( If you need a custom path, set `channels.line.webhookPath` or `channels.line.accounts..webhookPath` and update the URL accordingly. +Security note: + +- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification. + ## Configure Minimal config: diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 43777f648ad..483b6cd9ed3 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -535,7 +535,7 @@ describe("BlueBubbles webhook monitor", () => { // Create a request that never sends data or ends (simulates slow-loris) const req = new EventEmitter() as IncomingMessage; req.method = "POST"; - req.url = "/bluebubbles-webhook"; + req.url = "/bluebubbles-webhook?password=test-password"; req.headers = {}; (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1", @@ -558,6 +558,37 @@ describe("BlueBubbles webhook monitor", () => { } }); + it("rejects unauthorized requests before reading the body", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = new EventEmitter() as IncomingMessage; + req.method = "POST"; + req.url = "/bluebubbles-webhook?password=wrong-token"; + req.headers = {}; + const onSpy = vi.spyOn(req, "on"); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); + }); + it("authenticates via password query parameter", async () => { const account = createMockAccount({ password: "secret-token" }); const config: OpenClawConfig = {}; diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 48646fb7975..7ec98279dbf 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -2,11 +2,10 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { - isRequestBodyLimitError, - readRequestBodyWithLimit, + beginWebhookRequestPipelineOrReject, + createWebhookInFlightLimiter, registerWebhookTargetWithPluginRoute, - rejectNonPostWebhookRequest, - requestBodyErrorToText, + readWebhookBodyOrReject, resolveSingleWebhookTarget, resolveWebhookTargets, } from "openclaw/plugin-sdk"; @@ -114,6 +113,7 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized } const webhookTargets = new Map(); +const webhookInFlightLimiter = createWebhookInFlightLimiter(); type BlueBubblesDebouncer = { enqueue: (item: BlueBubblesDebounceEntry) => Promise; @@ -262,10 +262,6 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v }; } -type ReadBlueBubblesWebhookBodyResult = - | { ok: true; value: unknown } - | { ok: false; statusCode: number; error: string }; - function parseBlueBubblesWebhookPayload( rawBody: string, ): { ok: true; value: unknown } | { ok: false; error: string } { @@ -289,36 +285,6 @@ function parseBlueBubblesWebhookPayload( } } -async function readBlueBubblesWebhookBody( - req: IncomingMessage, - maxBytes: number, -): Promise { - try { - const rawBody = await readRequestBodyWithLimit(req, { - maxBytes, - timeoutMs: 30_000, - }); - const parsed = parseBlueBubblesWebhookPayload(rawBody); - if (!parsed.ok) { - return { ok: false, statusCode: 400, error: parsed.error }; - } - return parsed; - } catch (error) { - if (isRequestBodyLimitError(error)) { - return { - ok: false, - statusCode: error.statusCode, - error: requestBodyErrorToText(error.code), - }; - } - return { - ok: false, - statusCode: 400, - error: error instanceof Error ? error.message : String(error), - }; - } -} - function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -367,137 +333,158 @@ export async function handleBlueBubblesWebhookRequest( } const { path, targets } = resolved; const url = new URL(req.url ?? "/", "http://localhost"); - - if (rejectNonPostWebhookRequest(req, res)) { - return true; - } - - const body = await readBlueBubblesWebhookBody(req, 1024 * 1024); - if (!body.ok) { - res.statusCode = body.statusCode; - res.end(body.error ?? "invalid payload"); - console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); - return true; - } - - const payload = asRecord(body.value) ?? {}; - const firstTarget = targets[0]; - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`, - ); - } - const eventTypeRaw = payload.type; - const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : ""; - const allowedEventTypes = new Set([ - "new-message", - "updated-message", - "message-reaction", - "reaction", - ]); - if (eventType && !allowedEventTypes.has(eventType)) { - res.statusCode = 200; - res.end("ok"); - if (firstTarget) { - logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`); - } - return true; - } - const reaction = normalizeWebhookReaction(payload); - if ( - (eventType === "updated-message" || - eventType === "message-reaction" || - eventType === "reaction") && - !reaction - ) { - res.statusCode = 200; - res.end("ok"); - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook ignored ${eventType || "event"} without reaction`, - ); - } - return true; - } - const message = reaction ? null : normalizeWebhookMessage(payload); - if (!message && !reaction) { - res.statusCode = 400; - res.end("invalid payload"); - console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); - return true; - } - - const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); - const headerToken = - req.headers["x-guid"] ?? - req.headers["x-password"] ?? - req.headers["x-bluebubbles-guid"] ?? - req.headers["authorization"]; - const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; - const matchedTarget = resolveSingleWebhookTarget(targets, (target) => { - const token = target.account.config.password?.trim() ?? ""; - return safeEqualSecret(guid, token); + const requestLifecycle = beginWebhookRequestPipelineOrReject({ + req, + res, + allowMethods: ["POST"], + inFlightLimiter: webhookInFlightLimiter, + inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`, }); - - if (matchedTarget.kind === "none") { - res.statusCode = 401; - res.end("unauthorized"); - console.warn( - `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, - ); + if (!requestLifecycle.ok) { return true; } - if (matchedTarget.kind === "ambiguous") { - res.statusCode = 401; - res.end("ambiguous webhook target"); - console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`); - return true; - } - - const target = matchedTarget.target; - target.statusSink?.({ lastInboundAt: Date.now() }); - if (reaction) { - processReaction(reaction, target).catch((err) => { - target.runtime.error?.( - `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`, - ); + try { + const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); + const headerToken = + req.headers["x-guid"] ?? + req.headers["x-password"] ?? + req.headers["x-bluebubbles-guid"] ?? + req.headers["authorization"]; + const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; + const matchedTarget = resolveSingleWebhookTarget(targets, (target) => { + const token = target.account.config.password?.trim() ?? ""; + return safeEqualSecret(guid, token); }); - } else if (message) { - // Route messages through debouncer to coalesce rapid-fire events - // (e.g., text message + URL balloon arriving as separate webhooks) - const debouncer = getOrCreateDebouncer(target); - debouncer.enqueue({ message, target }).catch((err) => { - target.runtime.error?.( - `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, - ); - }); - } - res.statusCode = 200; - res.end("ok"); - if (reaction) { + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + console.warn( + `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, + ); + return true; + } + + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`); + return true; + } + + const target = matchedTarget.target; + const body = await readWebhookBodyOrReject({ + req, + res, + profile: "post-auth", + invalidBodyMessage: "invalid payload", + }); + if (!body.ok) { + console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`); + return true; + } + + const parsed = parseBlueBubblesWebhookPayload(body.value); + if (!parsed.ok) { + res.statusCode = 400; + res.end(parsed.error); + console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`); + return true; + } + + const payload = asRecord(parsed.value) ?? {}; + const firstTarget = targets[0]; if (firstTarget) { logVerbose( firstTarget.core, firstTarget.runtime, - `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`, + `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`, ); } - } else if (message) { - if (firstTarget) { - logVerbose( - firstTarget.core, - firstTarget.runtime, - `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, - ); + const eventTypeRaw = payload.type; + const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : ""; + const allowedEventTypes = new Set([ + "new-message", + "updated-message", + "message-reaction", + "reaction", + ]); + if (eventType && !allowedEventTypes.has(eventType)) { + res.statusCode = 200; + res.end("ok"); + if (firstTarget) { + logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`); + } + return true; } + const reaction = normalizeWebhookReaction(payload); + if ( + (eventType === "updated-message" || + eventType === "message-reaction" || + eventType === "reaction") && + !reaction + ) { + res.statusCode = 200; + res.end("ok"); + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook ignored ${eventType || "event"} without reaction`, + ); + } + return true; + } + const message = reaction ? null : normalizeWebhookMessage(payload); + if (!message && !reaction) { + res.statusCode = 400; + res.end("invalid payload"); + console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); + return true; + } + + target.statusSink?.({ lastInboundAt: Date.now() }); + if (reaction) { + processReaction(reaction, target).catch((err) => { + target.runtime.error?.( + `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`, + ); + }); + } else if (message) { + // Route messages through debouncer to coalesce rapid-fire events + // (e.g., text message + URL balloon arriving as separate webhooks) + const debouncer = getOrCreateDebouncer(target); + debouncer.enqueue({ message, target }).catch((err) => { + target.runtime.error?.( + `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, + ); + }); + } + + res.statusCode = 200; + res.end("ok"); + if (reaction) { + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`, + ); + } + } else if (message) { + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, + ); + } + } + return true; + } finally { + requestLifecycle.release(); } - return true; } export async function monitorBlueBubblesProvider( diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 49ef6ce3263..97c9c979398 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,12 +1,13 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { + beginWebhookRequestPipelineOrReject, + createWebhookInFlightLimiter, GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, createReplyPrefixOptions, - readJsonBodyWithLimit, + readJsonWebhookBodyOrReject, registerWebhookTargetWithPluginRoute, - rejectNonPostWebhookRequest, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -15,7 +16,6 @@ import { resolveWebhookPath, resolveWebhookTargets, warnMissingProviderGroupPolicyFallbackOnce, - requestBodyErrorToText, resolveMentionGatingWithBypass, resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk"; @@ -67,6 +67,7 @@ type WebhookTarget = { }; const webhookTargets = new Map(); +const webhookInFlightLimiter = createWebhookInFlightLimiter(); function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) { if (core.logging.shouldLogVerbose()) { @@ -137,49 +138,31 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | return undefined; } -export async function handleGoogleChatWebhookRequest( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const resolved = resolveWebhookTargets(req, webhookTargets); - if (!resolved) { - return false; - } - const { targets } = resolved; - - if (rejectNonPostWebhookRequest(req, res)) { - return true; - } - - const authHeader = String(req.headers.authorization ?? ""); - const bearer = authHeader.toLowerCase().startsWith("bearer ") - ? authHeader.slice("bearer ".length) +function extractBearerToken(header: unknown): string { + const authHeader = Array.isArray(header) ? String(header[0] ?? "") : String(header ?? ""); + return authHeader.toLowerCase().startsWith("bearer ") + ? authHeader.slice("bearer ".length).trim() : ""; +} - const body = await readJsonBodyWithLimit(req, { - maxBytes: 1024 * 1024, - timeoutMs: 30_000, - emptyObjectOnEmpty: false, - }); - if (!body.ok) { - res.statusCode = - body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400; - res.end( - body.code === "REQUEST_BODY_TIMEOUT" - ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT") - : body.error, - ); - return true; - } +type ParsedGoogleChatInboundPayload = + | { ok: true; event: GoogleChatEvent; addOnBearerToken: string } + | { ok: false }; - let raw = body.value; +function parseGoogleChatInboundPayload( + raw: unknown, + res: ServerResponse, +): ParsedGoogleChatInboundPayload { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { res.statusCode = 400; res.end("invalid payload"); - return true; + return { ok: false }; } - // Transform Google Workspace Add-on format to standard Chat API format + let eventPayload = raw; + let addOnBearerToken = ""; + + // Transform Google Workspace Add-on format to standard Chat API format. const rawObj = raw as { commonEventObject?: { hostApp?: string }; chat?: { @@ -193,84 +176,173 @@ export async function handleGoogleChatWebhookRequest( if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) { const chat = rawObj.chat; const messagePayload = chat.messagePayload; - raw = { + eventPayload = { type: "MESSAGE", space: messagePayload?.space, message: messagePayload?.message, user: chat.user, eventTime: chat.eventTime, }; - - // For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken - const systemIdToken = rawObj.authorizationEventObject?.systemIdToken; - if (!bearer && systemIdToken) { - Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` }); - } + addOnBearerToken = String(rawObj.authorizationEventObject?.systemIdToken ?? "").trim(); } - const event = raw as GoogleChatEvent; - const eventType = event.type ?? (raw as { eventType?: string }).eventType; + const event = eventPayload as GoogleChatEvent; + const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType; if (typeof eventType !== "string") { res.statusCode = 400; res.end("invalid payload"); - return true; + return { ok: false }; } if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) { res.statusCode = 400; res.end("invalid payload"); - return true; + return { ok: false }; } if (eventType === "MESSAGE") { if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) { res.statusCode = 400; res.end("invalid payload"); - return true; + return { ok: false }; } } - // Re-extract bearer in case it was updated from Add-on format - const authHeaderNow = String(req.headers.authorization ?? ""); - const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ") - ? authHeaderNow.slice("bearer ".length) - : bearer; + return { ok: true, event, addOnBearerToken }; +} - const matchedTarget = await resolveSingleWebhookTargetAsync(targets, async (target) => { - const audienceType = target.audienceType; - const audience = target.audience; +async function resolveGoogleChatWebhookTargetByBearer( + targets: readonly WebhookTarget[], + bearer: string, +) { + return await resolveSingleWebhookTargetAsync(targets, async (target) => { const verification = await verifyGoogleChatRequest({ - bearer: effectiveBearer, - audienceType, - audience, + bearer, + audienceType: target.audienceType, + audience: target.audience, }); return verification.ok; }); +} - if (matchedTarget.kind === "none") { - res.statusCode = 401; - res.end("unauthorized"); - return true; +export async function handleGoogleChatWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const resolved = resolveWebhookTargets(req, webhookTargets); + if (!resolved) { + return false; } + const { path, targets } = resolved; - if (matchedTarget.kind === "ambiguous") { - res.statusCode = 401; - res.end("ambiguous webhook target"); - return true; - } - - const selected = matchedTarget.target; - selected.statusSink?.({ lastInboundAt: Date.now() }); - processGoogleChatEvent(event, selected).catch((err) => { - selected?.runtime.error?.( - `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`, - ); + const requestLifecycle = beginWebhookRequestPipelineOrReject({ + req, + res, + allowMethods: ["POST"], + requireJsonContentType: true, + inFlightLimiter: webhookInFlightLimiter, + inFlightKey: `${path}:${req.socket?.remoteAddress ?? "unknown"}`, }); + if (!requestLifecycle.ok) { + return true; + } - res.statusCode = 200; - res.setHeader("Content-Type", "application/json"); - res.end("{}"); - return true; + try { + const headerBearer = extractBearerToken(req.headers.authorization); + let matchedTarget: Awaited> | null = + null; + let parsedEvent: GoogleChatEvent | null = null; + let addOnBearerToken = ""; + + if (headerBearer) { + matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, headerBearer); + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + return true; + } + + const body = await readJsonWebhookBodyOrReject({ + req, + res, + profile: "post-auth", + emptyObjectOnEmpty: false, + invalidJsonMessage: "invalid payload", + }); + if (!body.ok) { + return true; + } + + const parsed = parseGoogleChatInboundPayload(body.value, res); + if (!parsed.ok) { + return true; + } + parsedEvent = parsed.event; + addOnBearerToken = parsed.addOnBearerToken; + } else { + const body = await readJsonWebhookBodyOrReject({ + req, + res, + profile: "pre-auth", + emptyObjectOnEmpty: false, + invalidJsonMessage: "invalid payload", + }); + if (!body.ok) { + return true; + } + + const parsed = parseGoogleChatInboundPayload(body.value, res); + if (!parsed.ok) { + return true; + } + parsedEvent = parsed.event; + addOnBearerToken = parsed.addOnBearerToken; + + if (!addOnBearerToken) { + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + + matchedTarget = await resolveGoogleChatWebhookTargetByBearer(targets, addOnBearerToken); + if (matchedTarget.kind === "none") { + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + if (matchedTarget.kind === "ambiguous") { + res.statusCode = 401; + res.end("ambiguous webhook target"); + return true; + } + } + + if (!matchedTarget || !parsedEvent) { + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + + const selected = matchedTarget.target; + selected.statusSink?.({ lastInboundAt: Date.now() }); + processGoogleChatEvent(parsedEvent, selected).catch((err) => { + selected.runtime.error?.( + `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`, + ); + }); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end("{}"); + return true; + } finally { + requestLifecycle.release(); + } } async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) { diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index f25d55c13b5..0aafa77e09f 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -21,6 +21,7 @@ function createWebhookRequest(params: { const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: (error?: Error) => IncomingMessage; + on: (event: string, listener: (...args: unknown[]) => void) => IncomingMessage; }; req.method = "POST"; req.url = params.path ?? "/googlechat"; @@ -29,21 +30,50 @@ function createWebhookRequest(params: { "content-type": "application/json", }; req.destroyed = false; + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; req.destroy = () => { req.destroyed = true; return req; }; - void Promise.resolve().then(() => { - req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8")); - if (!req.destroyed) { - req.emit("end"); + const originalOn = req.on.bind(req); + let bodyScheduled = false; + req.on = ((event: string, listener: (...args: unknown[]) => void) => { + const result = originalOn(event, listener); + if (!bodyScheduled && event === "data") { + bodyScheduled = true; + void Promise.resolve().then(() => { + req.emit("data", Buffer.from(JSON.stringify(params.payload), "utf-8")); + if (!req.destroyed) { + req.emit("end"); + } + }); } - }); + return result; + }) as IncomingMessage["on"]; return req; } +function createHeaderOnlyWebhookRequest(params: { + authorization?: string; + path?: string; +}): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = "POST"; + req.url = params.path ?? "/googlechat"; + req.headers = { + authorization: params.authorization ?? "", + "content-type": "application/json", + }; + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + return req; +} + const baseAccount = (accountId: string) => ({ accountId, @@ -178,4 +208,59 @@ describe("Google Chat webhook routing", () => { unregister(); } }); + + it("rejects invalid bearer before attempting to read the body", async () => { + vi.mocked(verifyGoogleChatRequest).mockResolvedValue({ ok: false, reason: "invalid" }); + const { unregister } = registerTwoTargets(); + + try { + const req = createHeaderOnlyWebhookRequest({ + authorization: "Bearer invalid-token", + }); + const onSpy = vi.spyOn(req, "on"); + const res = createMockServerResponse(); + const handled = await handleGoogleChatWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); + } finally { + unregister(); + } + }); + + it("supports add-on requests that provide systemIdToken in the body", async () => { + vi.mocked(verifyGoogleChatRequest) + .mockResolvedValueOnce({ ok: false, reason: "invalid" }) + .mockResolvedValueOnce({ ok: true }); + const { sinkA, sinkB, unregister } = registerTwoTargets(); + + try { + const res = createMockServerResponse(); + const handled = await handleGoogleChatWebhookRequest( + createWebhookRequest({ + payload: { + commonEventObject: { hostApp: "CHAT" }, + authorizationEventObject: { systemIdToken: "addon-token" }, + chat: { + eventTime: "2026-03-02T00:00:00.000Z", + user: { name: "users/12345", displayName: "Test User" }, + messagePayload: { + space: { name: "spaces/AAA" }, + message: { text: "Hello from add-on" }, + }, + }, + }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(sinkA).not.toHaveBeenCalled(); + expect(sinkB).toHaveBeenCalledTimes(1); + } finally { + unregister(); + } + }); }); diff --git a/package.json b/package.json index 2d4dd5cd1dd..599d0af8622 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -108,6 +108,7 @@ "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", + "lint:webhook:no-low-level-body-read": "node scripts/check-webhook-auth-body-order.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", diff --git a/scripts/check-webhook-auth-body-order.mjs b/scripts/check-webhook-auth-body-order.mjs new file mode 100644 index 00000000000..282909db4b8 --- /dev/null +++ b/scripts/check-webhook-auth-body-order.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import path from "node:path"; +import ts from "typescript"; +import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; +import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; + +const sourceRoots = ["extensions"]; +const enforcedFiles = new Set([ + "extensions/bluebubbles/src/monitor.ts", + "extensions/googlechat/src/monitor.ts", +]); +const blockedCallees = new Set(["readJsonBodyWithLimit", "readRequestBodyWithLimit"]); + +function getCalleeName(expression) { + const callee = unwrapExpression(expression); + if (ts.isIdentifier(callee)) { + return callee.text; + } + if (ts.isPropertyAccessExpression(callee)) { + return callee.name.text; + } + return null; +} + +export function findBlockedWebhookBodyReadLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const lines = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const calleeName = getCalleeName(node.expression); + if (calleeName && blockedCallees.has(calleeName)) { + lines.push(toLine(sourceFile, node.expression)); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return lines; +} + +export async function main() { + await runCallsiteGuard({ + importMetaUrl: import.meta.url, + sourceRoots, + findCallLines: findBlockedWebhookBodyReadLines, + skipRelativePath: (relPath) => !enforcedFiles.has(relPath.replaceAll(path.sep, "/")), + header: "Found forbidden low-level body reads in auth-sensitive webhook handlers:", + footer: + "Use plugin-sdk webhook guards (`readJsonWebhookBodyOrReject` / `readWebhookBodyOrReject`) with explicit pre-auth/post-auth profiles.", + }); +} + +runAsScript(import.meta.url, main); diff --git a/src/line/webhook-node.test.ts b/src/line/webhook-node.test.ts index 0414f63d243..07035c64521 100644 --- a/src/line/webhook-node.test.ts +++ b/src/line/webhook-node.test.ts @@ -126,6 +126,31 @@ describe("createLineNodeWebhookHandler", () => { expect(bot.handleWebhook).not.toHaveBeenCalled(); }); + it("uses strict pre-auth limits for signed POST requests", async () => { + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const bot = { handleWebhook: vi.fn(async () => {}) }; + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => { + expect(maxBytes).toBe(64 * 1024); + expect(timeoutMs).toBe(5_000); + return rawBody; + }); + const handler = createLineNodeWebhookHandler({ + channelSecret: "secret", + bot, + runtime, + readBody, + maxBodyBytes: 1024 * 1024, + }); + + const { res } = createRes(); + await runSignedPost({ handler, rawBody, secret: "secret", res }); + + expect(res.statusCode).toBe(200); + expect(readBody).toHaveBeenCalledTimes(1); + expect(bot.handleWebhook).toHaveBeenCalledTimes(1); + }); + it("rejects invalid signature", async () => { const rawBody = JSON.stringify({ events: [{ type: "message" }] }); const { bot, handler } = createPostWebhookTestHarness(rawBody); diff --git a/src/line/webhook-node.ts b/src/line/webhook-node.ts index da914c90a06..81e2a082210 100644 --- a/src/line/webhook-node.ts +++ b/src/line/webhook-node.ts @@ -11,20 +11,22 @@ import { validateLineSignature } from "./signature.js"; import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js"; const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; +const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024; const LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES = 4 * 1024; -const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000; +const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000; export async function readLineWebhookRequestBody( req: IncomingMessage, maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES, + timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS, ): Promise { return await readRequestBodyWithLimit(req, { maxBytes, - timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS, + timeoutMs, }); } -type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise; +type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise; export function createLineNodeWebhookHandler(params: { channelSecret: string; @@ -64,9 +66,9 @@ export function createLineNodeWebhookHandler(params: { : undefined; const hasSignature = typeof signature === "string" && signature.trim().length > 0; const bodyLimit = hasSignature - ? maxBodyBytes + ? Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES) : Math.min(maxBodyBytes, LINE_WEBHOOK_UNSIGNED_MAX_BODY_BYTES); - const rawBody = await readBody(req, bodyLimit); + const rawBody = await readBody(req, bodyLimit, LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS); // Parse once; we may need it for verification requests and for event processing. const body = parseLineWebhookBody(rawBody); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index da4f01c5c10..6d77dce043a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -136,9 +136,15 @@ export type { } from "./webhook-targets.js"; export { applyBasicWebhookRequestGuards, + beginWebhookRequestPipelineOrReject, + createWebhookInFlightLimiter, isJsonContentType, + readWebhookBodyOrReject, readJsonWebhookBodyOrReject, + WEBHOOK_BODY_READ_DEFAULTS, + WEBHOOK_IN_FLIGHT_DEFAULTS, } from "./webhook-request-guards.js"; +export type { WebhookBodyReadProfile, WebhookInFlightLimiter } from "./webhook-request-guards.js"; export type { AgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { diff --git a/src/plugin-sdk/webhook-request-guards.test.ts b/src/plugin-sdk/webhook-request-guards.test.ts index 90b492c657a..91b7f4823db 100644 --- a/src/plugin-sdk/webhook-request-guards.test.ts +++ b/src/plugin-sdk/webhook-request-guards.test.ts @@ -5,7 +5,10 @@ import { createMockServerResponse } from "../test-utils/mock-http-response.js"; import { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; import { applyBasicWebhookRequestGuards, + beginWebhookRequestPipelineOrReject, + createWebhookInFlightLimiter, isJsonContentType, + readWebhookBodyOrReject, readJsonWebhookBodyOrReject, } from "./webhook-request-guards.js"; @@ -158,3 +161,76 @@ describe("readJsonWebhookBodyOrReject", () => { expect(res.body).toBe("Bad Request"); }); }); + +describe("readWebhookBodyOrReject", () => { + it("returns raw body contents", async () => { + const req = createMockRequest({ chunks: ["plain text"] }); + const res = createMockServerResponse(); + await expect( + readWebhookBodyOrReject({ + req, + res, + }), + ).resolves.toEqual({ ok: true, value: "plain text" }); + }); + + it("enforces strict pre-auth default body limits", async () => { + const req = createMockRequest({ + headers: { "content-length": String(70 * 1024) }, + }); + const res = createMockServerResponse(); + await expect( + readWebhookBodyOrReject({ + req, + res, + profile: "pre-auth", + }), + ).resolves.toEqual({ ok: false }); + expect(res.statusCode).toBe(413); + }); +}); + +describe("beginWebhookRequestPipelineOrReject", () => { + it("enforces in-flight request limits and releases slots", () => { + const limiter = createWebhookInFlightLimiter({ + maxInFlightPerKey: 1, + maxTrackedKeys: 10, + }); + + const first = beginWebhookRequestPipelineOrReject({ + req: createMockRequest({ method: "POST" }), + res: createMockServerResponse(), + allowMethods: ["POST"], + inFlightLimiter: limiter, + inFlightKey: "ip:127.0.0.1", + }); + expect(first.ok).toBe(true); + + const secondRes = createMockServerResponse(); + const second = beginWebhookRequestPipelineOrReject({ + req: createMockRequest({ method: "POST" }), + res: secondRes, + allowMethods: ["POST"], + inFlightLimiter: limiter, + inFlightKey: "ip:127.0.0.1", + }); + expect(second.ok).toBe(false); + expect(secondRes.statusCode).toBe(429); + + if (first.ok) { + first.release(); + } + + const third = beginWebhookRequestPipelineOrReject({ + req: createMockRequest({ method: "POST" }), + res: createMockServerResponse(), + allowMethods: ["POST"], + inFlightLimiter: limiter, + inFlightKey: "ip:127.0.0.1", + }); + expect(third.ok).toBe(true); + if (third.ok) { + third.release(); + } + }); +}); diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index 956ec09c2cf..ce447212ff7 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -1,7 +1,106 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { readJsonBodyWithLimit, requestBodyErrorToText } from "../infra/http-body.js"; +import { + isRequestBodyLimitError, + readJsonBodyWithLimit, + readRequestBodyWithLimit, + requestBodyErrorToText, +} from "../infra/http-body.js"; +import { pruneMapToMaxSize } from "../infra/map-size.js"; import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; +export type WebhookBodyReadProfile = "pre-auth" | "post-auth"; + +export const WEBHOOK_BODY_READ_DEFAULTS = Object.freeze({ + preAuth: { + maxBytes: 64 * 1024, + timeoutMs: 5_000, + }, + postAuth: { + maxBytes: 1024 * 1024, + timeoutMs: 30_000, + }, +}); + +export const WEBHOOK_IN_FLIGHT_DEFAULTS = Object.freeze({ + maxInFlightPerKey: 8, + maxTrackedKeys: 4_096, +}); + +export type WebhookInFlightLimiter = { + tryAcquire: (key: string) => boolean; + release: (key: string) => void; + size: () => number; + clear: () => void; +}; + +function resolveWebhookBodyReadLimits(params: { + maxBytes?: number; + timeoutMs?: number; + profile?: WebhookBodyReadProfile; +}): { maxBytes: number; timeoutMs: number } { + const defaults = + params.profile === "pre-auth" + ? WEBHOOK_BODY_READ_DEFAULTS.preAuth + : WEBHOOK_BODY_READ_DEFAULTS.postAuth; + const maxBytes = + typeof params.maxBytes === "number" && Number.isFinite(params.maxBytes) && params.maxBytes > 0 + ? Math.floor(params.maxBytes) + : defaults.maxBytes; + const timeoutMs = + typeof params.timeoutMs === "number" && + Number.isFinite(params.timeoutMs) && + params.timeoutMs > 0 + ? Math.floor(params.timeoutMs) + : defaults.timeoutMs; + return { maxBytes, timeoutMs }; +} + +export function createWebhookInFlightLimiter(options?: { + maxInFlightPerKey?: number; + maxTrackedKeys?: number; +}): WebhookInFlightLimiter { + const maxInFlightPerKey = Math.max( + 1, + Math.floor(options?.maxInFlightPerKey ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey), + ); + const maxTrackedKeys = Math.max( + 1, + Math.floor(options?.maxTrackedKeys ?? WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys), + ); + const active = new Map(); + + return { + tryAcquire: (key: string) => { + if (!key) { + return true; + } + const current = active.get(key) ?? 0; + if (current >= maxInFlightPerKey) { + return false; + } + active.set(key, current + 1); + pruneMapToMaxSize(active, maxTrackedKeys); + return true; + }, + release: (key: string) => { + if (!key) { + return; + } + const current = active.get(key); + if (current === undefined) { + return; + } + if (current <= 1) { + active.delete(key); + return; + } + active.set(key, current - 1); + }, + size: () => active.size, + clear: () => active.clear(), + }; +} + export function isJsonContentType(value: string | string[] | undefined): boolean { const first = Array.isArray(value) ? value[0] : value; if (!first) { @@ -51,17 +150,109 @@ export function applyBasicWebhookRequestGuards(params: { return true; } +export function beginWebhookRequestPipelineOrReject(params: { + req: IncomingMessage; + res: ServerResponse; + allowMethods?: readonly string[]; + rateLimiter?: FixedWindowRateLimiter; + rateLimitKey?: string; + nowMs?: number; + requireJsonContentType?: boolean; + inFlightLimiter?: WebhookInFlightLimiter; + inFlightKey?: string; + inFlightLimitStatusCode?: number; + inFlightLimitMessage?: string; +}): { ok: true; release: () => void } | { ok: false } { + if ( + !applyBasicWebhookRequestGuards({ + req: params.req, + res: params.res, + allowMethods: params.allowMethods, + rateLimiter: params.rateLimiter, + rateLimitKey: params.rateLimitKey, + nowMs: params.nowMs, + requireJsonContentType: params.requireJsonContentType, + }) + ) { + return { ok: false }; + } + + const inFlightKey = params.inFlightKey ?? ""; + const inFlightLimiter = params.inFlightLimiter; + if (inFlightLimiter && inFlightKey && !inFlightLimiter.tryAcquire(inFlightKey)) { + params.res.statusCode = params.inFlightLimitStatusCode ?? 429; + params.res.end(params.inFlightLimitMessage ?? "Too Many Requests"); + return { ok: false }; + } + + let released = false; + return { + ok: true, + release: () => { + if (released) { + return; + } + released = true; + if (inFlightLimiter && inFlightKey) { + inFlightLimiter.release(inFlightKey); + } + }, + }; +} + +export async function readWebhookBodyOrReject(params: { + req: IncomingMessage; + res: ServerResponse; + maxBytes?: number; + timeoutMs?: number; + profile?: WebhookBodyReadProfile; + invalidBodyMessage?: string; +}): Promise<{ ok: true; value: string } | { ok: false }> { + const limits = resolveWebhookBodyReadLimits({ + maxBytes: params.maxBytes, + timeoutMs: params.timeoutMs, + profile: params.profile, + }); + + try { + const raw = await readRequestBodyWithLimit(params.req, limits); + return { ok: true, value: raw }; + } catch (error) { + if (isRequestBodyLimitError(error)) { + params.res.statusCode = + error.code === "PAYLOAD_TOO_LARGE" + ? 413 + : error.code === "REQUEST_BODY_TIMEOUT" + ? 408 + : 400; + params.res.end(requestBodyErrorToText(error.code)); + return { ok: false }; + } + params.res.statusCode = 400; + params.res.end( + params.invalidBodyMessage ?? (error instanceof Error ? error.message : String(error)), + ); + return { ok: false }; + } +} + export async function readJsonWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; - maxBytes: number; + maxBytes?: number; timeoutMs?: number; + profile?: WebhookBodyReadProfile; emptyObjectOnEmpty?: boolean; invalidJsonMessage?: string; }): Promise<{ ok: true; value: unknown } | { ok: false }> { - const body = await readJsonBodyWithLimit(params.req, { + const limits = resolveWebhookBodyReadLimits({ maxBytes: params.maxBytes, timeoutMs: params.timeoutMs, + profile: params.profile, + }); + const body = await readJsonBodyWithLimit(params.req, { + maxBytes: limits.maxBytes, + timeoutMs: limits.timeoutMs, emptyObjectOnEmpty: params.emptyObjectOnEmpty, }); if (body.ok) {