From 7a3918da13d055d4a09ccab4176e5362068c97b1 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:31:57 -0700 Subject: [PATCH 1/4] fix(webhooks): cap request body size on public webhook receivers Public, unauthenticated webhook endpoints read the entire request body into memory before any lookup or signature verification, letting a caller exhaust pod memory with arbitrarily large bodies. Bound the body via the existing size-limited stream reader (content-length guard + streamed cap) and return 413 on oversize. Applies to parseWebhookBody (trigger receiver) and the agentmail route. Cap defaults to 10 MB, overridable via WEBHOOK_MAX_REQUEST_BYTES. --- apps/sim/app/api/webhooks/agentmail/route.ts | 42 +++++++++++++++++++- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/webhooks/processor.ts | 38 +++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 9c4333c4fec..9784773e09c 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -19,7 +19,13 @@ import { agentMailMessageSchema, webhookSvixHeadersSchema, } from '@/lib/api/contracts/webhooks' +import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' @@ -29,9 +35,43 @@ const logger = createLogger('AgentMailWebhook') const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@'] const MAX_EMAILS_PER_HOUR = 20 +/** + * Bound the unauthenticated AgentMail webhook body before buffering it for Svix + * signature verification, so an oversized payload cannot exhaust pod memory. + */ +const AGENTMAIL_MAX_BODY_BYTES = + Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 + +const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body' + +async function readAgentMailBody(req: Request): Promise { + assertContentLengthWithinLimit(req.headers, AGENTMAIL_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL) + const stream = req.body + if (!stream) { + return req.text() + } + const buffer = await readStreamToBufferWithLimit(stream, { + maxBytes: AGENTMAIL_MAX_BODY_BYTES, + label: AGENTMAIL_BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + export const POST = withRouteHandler(async (req: Request) => { try { - const rawBody = await req.text() + let rawBody: string + try { + rawBody = await readAgentMailBody(req) + } catch (bodyError) { + if (isPayloadSizeLimitError(bodyError)) { + logger.warn('Rejected oversized AgentMail webhook body', { + maxBytes: AGENTMAIL_MAX_BODY_BYTES, + observedBytes: bodyError.observedBytes, + }) + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw bodyError + } const headersResult = webhookSvixHeadersSchema.safeParse({ 'svix-id': req.headers.get('svix-id'), 'svix-timestamp': req.headers.get('svix-timestamp'), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 378ad933d15..a607225ff79 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -248,6 +248,7 @@ export const env = createEnv({ ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod API_MAX_JSON_BODY_BYTES: z.string().optional().default('52428800'),// Default max JSON request body size for contract routes (50 MB) CHAT_MAX_REQUEST_BYTES: z.string().optional().default('230686720'),// Max request body size for the public deployed-chat endpoint (220 MB; covers 15 base64 file attachments) + WEBHOOK_MAX_REQUEST_BYTES: z.string().optional().default('10485760'),// Max request body size for public webhook receiver endpoints (10 MB; provider payloads rarely exceed a few MB) // Rate Limiting Configuration RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 3352048b94f..9785820e0b7 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -9,7 +9,13 @@ import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscri import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' +import { env } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' import { @@ -71,19 +77,47 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ return { valid: true } } +/** + * Maximum size of a webhook request body read into memory. The webhook receiver + * is public and unauthenticated, so the body must be bounded before it is + * buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a + * few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`. + */ +export const WEBHOOK_MAX_BODY_BYTES = + Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 + +const WEBHOOK_BODY_LABEL = 'Webhook request body' + export async function parseWebhookBody( request: NextRequest, requestId: string ): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { - const requestClone = request.clone() - rawBody = await requestClone.text() + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) + + const stream = request.clone().body + if (stream) { + const buffer = await readStreamToBufferWithLimit(stream, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: WEBHOOK_BODY_LABEL, + }) + rawBody = new TextDecoder().decode(buffer) + } else { + rawBody = await request.clone().text() + } if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } } catch (bodyError) { + if (isPayloadSizeLimitError(bodyError)) { + logger.warn(`[${requestId}] Rejected oversized webhook body`, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + observedBytes: bodyError.observedBytes, + }) + return new NextResponse('Request body too large', { status: 413 }) + } logger.error(`[${requestId}] Failed to read request body`, { error: toError(bodyError).message, }) From c40c6d79b8b28770348112cea63d5a7ef93ae8be Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:41:40 -0700 Subject: [PATCH 2/4] refactor(webhooks): extract shared body-size cap to constants module Address review feedback: hoist WEBHOOK_MAX_BODY_BYTES into a single lib/webhooks/constants.ts so the trigger receiver and AgentMail route share one source of truth instead of recomputing the env-derived cap (prevents drift). Also drop the redundant request clone when the body stream is null. --- apps/sim/app/api/webhooks/agentmail/route.ts | 15 ++++++--------- apps/sim/lib/webhooks/constants.ts | 12 ++++++++++++ apps/sim/lib/webhooks/processor.ts | 15 ++++----------- 3 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 apps/sim/lib/webhooks/constants.ts diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 9784773e09c..6f48e8b1266 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -19,7 +19,6 @@ import { agentMailMessageSchema, webhookSvixHeadersSchema, } from '@/lib/api/contracts/webhooks' -import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { assertContentLengthWithinLimit, @@ -29,29 +28,27 @@ import { import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' const logger = createLogger('AgentMailWebhook') const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@'] const MAX_EMAILS_PER_HOUR = 20 +const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body' + /** * Bound the unauthenticated AgentMail webhook body before buffering it for Svix * signature verification, so an oversized payload cannot exhaust pod memory. */ -const AGENTMAIL_MAX_BODY_BYTES = - Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 - -const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body' - async function readAgentMailBody(req: Request): Promise { - assertContentLengthWithinLimit(req.headers, AGENTMAIL_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL) + assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL) const stream = req.body if (!stream) { return req.text() } const buffer = await readStreamToBufferWithLimit(stream, { - maxBytes: AGENTMAIL_MAX_BODY_BYTES, + maxBytes: WEBHOOK_MAX_BODY_BYTES, label: AGENTMAIL_BODY_LABEL, }) return new TextDecoder().decode(buffer) @@ -65,7 +62,7 @@ export const POST = withRouteHandler(async (req: Request) => { } catch (bodyError) { if (isPayloadSizeLimitError(bodyError)) { logger.warn('Rejected oversized AgentMail webhook body', { - maxBytes: AGENTMAIL_MAX_BODY_BYTES, + maxBytes: WEBHOOK_MAX_BODY_BYTES, observedBytes: bodyError.observedBytes, }) return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) diff --git a/apps/sim/lib/webhooks/constants.ts b/apps/sim/lib/webhooks/constants.ts new file mode 100644 index 00000000000..bdd4556c590 --- /dev/null +++ b/apps/sim/lib/webhooks/constants.ts @@ -0,0 +1,12 @@ +import { env } from '@/lib/core/config/env' + +/** + * Maximum size of a webhook request body read into memory. The webhook receivers + * are public and unauthenticated, so the body must be bounded before it is + * buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a + * few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`. + * + * Shared by every public webhook receiver so the cap is a single source of truth. + */ +export const WEBHOOK_MAX_BODY_BYTES = + Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 9785820e0b7..aa4d9583a6f 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -9,7 +9,6 @@ import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscri import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { env } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' import { assertContentLengthWithinLimit, @@ -18,6 +17,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { preprocessExecution } from '@/lib/execution/preprocessing' +import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' import { getPendingWebhookVerification, matchesPendingWebhookVerificationProbe, @@ -77,15 +77,6 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ return { valid: true } } -/** - * Maximum size of a webhook request body read into memory. The webhook receiver - * is public and unauthenticated, so the body must be bounded before it is - * buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a - * few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`. - */ -export const WEBHOOK_MAX_BODY_BYTES = - Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 - const WEBHOOK_BODY_LABEL = 'Webhook request body' export async function parseWebhookBody( @@ -104,7 +95,9 @@ export async function parseWebhookBody( }) rawBody = new TextDecoder().decode(buffer) } else { - rawBody = await request.clone().text() + // A null body stream means the request carries no body, so the parsed + // body is empty — no second clone needed. + rawBody = '' } if (!rawBody || rawBody.length === 0) { From e84b0c32e89bf25a0297d1f01a82638af787982e Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:53:07 -0700 Subject: [PATCH 3/4] refactor(webhooks): drop redundant null-body branch in capped readers Both capped body readers had an `if (!stream)` fallback to an uncapped `.text()`/empty string. `readStreamToBufferWithLimit` already returns an empty buffer for a null stream, so the branch is redundant and the `.text()` fallback was a theoretical bypass (chunked request, no content-length, null body). Collapse both to a single capped read. --- apps/sim/app/api/webhooks/agentmail/route.ts | 9 ++++----- apps/sim/lib/webhooks/processor.ts | 20 ++++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 6f48e8b1266..3937026e5a6 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -43,11 +43,10 @@ const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body' */ async function readAgentMailBody(req: Request): Promise { assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL) - const stream = req.body - if (!stream) { - return req.text() - } - const buffer = await readStreamToBufferWithLimit(stream, { + // `readStreamToBufferWithLimit` returns an empty buffer for a null body, so a + // single capped read covers the empty-body case without an uncapped `.text()` + // fallback that could bypass the limit. + const buffer = await readStreamToBufferWithLimit(req.body, { maxBytes: WEBHOOK_MAX_BODY_BYTES, label: AGENTMAIL_BODY_LABEL, }) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index aa4d9583a6f..c565ee17a2c 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -87,18 +87,14 @@ export async function parseWebhookBody( try { assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) - const stream = request.clone().body - if (stream) { - const buffer = await readStreamToBufferWithLimit(stream, { - maxBytes: WEBHOOK_MAX_BODY_BYTES, - label: WEBHOOK_BODY_LABEL, - }) - rawBody = new TextDecoder().decode(buffer) - } else { - // A null body stream means the request carries no body, so the parsed - // body is empty — no second clone needed. - rawBody = '' - } + // `readStreamToBufferWithLimit` returns an empty buffer for a null body, so + // this single capped read covers the empty-body case too — no branch or + // redundant clone, and no uncapped `.text()` fallback to bypass. + const buffer = await readStreamToBufferWithLimit(request.clone().body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: WEBHOOK_BODY_LABEL, + }) + rawBody = new TextDecoder().decode(buffer) if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } From 5748e2341b2aa26891b6e983b35e2b370673275f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 15 Jun 2026 16:55:11 -0700 Subject: [PATCH 4/4] chore(webhooks): drop inline comments from capped body readers --- apps/sim/app/api/webhooks/agentmail/route.ts | 3 --- apps/sim/lib/webhooks/processor.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 3937026e5a6..b7aeb093c71 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -43,9 +43,6 @@ const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body' */ async function readAgentMailBody(req: Request): Promise { assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL) - // `readStreamToBufferWithLimit` returns an empty buffer for a null body, so a - // single capped read covers the empty-body case without an uncapped `.text()` - // fallback that could bypass the limit. const buffer = await readStreamToBufferWithLimit(req.body, { maxBytes: WEBHOOK_MAX_BODY_BYTES, label: AGENTMAIL_BODY_LABEL, diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index c565ee17a2c..5515d39d35a 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -87,9 +87,6 @@ export async function parseWebhookBody( try { assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL) - // `readStreamToBufferWithLimit` returns an empty buffer for a null body, so - // this single capped read covers the empty-body case too — no branch or - // redundant clone, and no uncapped `.text()` fallback to bypass. const buffer = await readStreamToBufferWithLimit(request.clone().body, { maxBytes: WEBHOOK_MAX_BODY_BYTES, label: WEBHOOK_BODY_LABEL,