Skip to content

Commit a09e393

Browse files
authored
fix(webhooks): cap request body size on public webhook receivers (#5075)
* 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. * 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. * 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. * chore(webhooks): drop inline comments from capped body readers
1 parent f277f5f commit a09e393

4 files changed

Lines changed: 69 additions & 3 deletions

File tree

apps/sim/app/api/webhooks/agentmail/route.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,51 @@ import {
2020
webhookSvixHeadersSchema,
2121
} from '@/lib/api/contracts/webhooks'
2222
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
23+
import {
24+
assertContentLengthWithinLimit,
25+
isPayloadSizeLimitError,
26+
readStreamToBufferWithLimit,
27+
} from '@/lib/core/utils/stream-limits'
2328
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2429
import { executeInboxTask } from '@/lib/mothership/inbox/executor'
2530
import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types'
31+
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
2632

2733
const logger = createLogger('AgentMailWebhook')
2834

2935
const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@']
3036
const MAX_EMAILS_PER_HOUR = 20
3137

38+
const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body'
39+
40+
/**
41+
* Bound the unauthenticated AgentMail webhook body before buffering it for Svix
42+
* signature verification, so an oversized payload cannot exhaust pod memory.
43+
*/
44+
async function readAgentMailBody(req: Request): Promise<string> {
45+
assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL)
46+
const buffer = await readStreamToBufferWithLimit(req.body, {
47+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
48+
label: AGENTMAIL_BODY_LABEL,
49+
})
50+
return new TextDecoder().decode(buffer)
51+
}
52+
3253
export const POST = withRouteHandler(async (req: Request) => {
3354
try {
34-
const rawBody = await req.text()
55+
let rawBody: string
56+
try {
57+
rawBody = await readAgentMailBody(req)
58+
} catch (bodyError) {
59+
if (isPayloadSizeLimitError(bodyError)) {
60+
logger.warn('Rejected oversized AgentMail webhook body', {
61+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
62+
observedBytes: bodyError.observedBytes,
63+
})
64+
return NextResponse.json({ error: 'Request body too large' }, { status: 413 })
65+
}
66+
throw bodyError
67+
}
3568
const headersResult = webhookSvixHeadersSchema.safeParse({
3669
'svix-id': req.headers.get('svix-id'),
3770
'svix-timestamp': req.headers.get('svix-timestamp'),

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export const env = createEnv({
248248
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
249249
API_MAX_JSON_BODY_BYTES: z.string().optional().default('52428800'),// Default max JSON request body size for contract routes (50 MB)
250250
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)
251+
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)
251252

252253
// Rate Limiting Configuration
253254
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)

apps/sim/lib/webhooks/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { env } from '@/lib/core/config/env'
2+
3+
/**
4+
* Maximum size of a webhook request body read into memory. The webhook receivers
5+
* are public and unauthenticated, so the body must be bounded before it is
6+
* buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a
7+
* few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`.
8+
*
9+
* Shared by every public webhook receiver so the cap is a single source of truth.
10+
*/
11+
export const WEBHOOK_MAX_BODY_BYTES =
12+
Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024

apps/sim/lib/webhooks/processor.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ import { tryAdmit } from '@/lib/core/admission/gate'
1010
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
1111
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
1212
import { isProd } from '@/lib/core/config/feature-flags'
13+
import {
14+
assertContentLengthWithinLimit,
15+
isPayloadSizeLimitError,
16+
readStreamToBufferWithLimit,
17+
} from '@/lib/core/utils/stream-limits'
1318
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
1419
import { preprocessExecution } from '@/lib/execution/preprocessing'
20+
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
1521
import {
1622
getPendingWebhookVerification,
1723
matchesPendingWebhookVerificationProbe,
@@ -71,19 +77,33 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{
7177
return { valid: true }
7278
}
7379

80+
const WEBHOOK_BODY_LABEL = 'Webhook request body'
81+
7482
export async function parseWebhookBody(
7583
request: NextRequest,
7684
requestId: string
7785
): Promise<{ body: unknown; rawBody: string } | NextResponse> {
7886
let rawBody: string | null = null
7987
try {
80-
const requestClone = request.clone()
81-
rawBody = await requestClone.text()
88+
assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL)
89+
90+
const buffer = await readStreamToBufferWithLimit(request.clone().body, {
91+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
92+
label: WEBHOOK_BODY_LABEL,
93+
})
94+
rawBody = new TextDecoder().decode(buffer)
8295

8396
if (!rawBody || rawBody.length === 0) {
8497
return { body: {}, rawBody: '' }
8598
}
8699
} catch (bodyError) {
100+
if (isPayloadSizeLimitError(bodyError)) {
101+
logger.warn(`[${requestId}] Rejected oversized webhook body`, {
102+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
103+
observedBytes: bodyError.observedBytes,
104+
})
105+
return new NextResponse('Request body too large', { status: 413 })
106+
}
87107
logger.error(`[${requestId}] Failed to read request body`, {
88108
error: toError(bodyError).message,
89109
})

0 commit comments

Comments
 (0)