Skip to content

Commit 7a3918d

Browse files
committed
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.
1 parent 3e2b641 commit 7a3918d

3 files changed

Lines changed: 78 additions & 3 deletions

File tree

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import {
1919
agentMailMessageSchema,
2020
webhookSvixHeadersSchema,
2121
} from '@/lib/api/contracts/webhooks'
22+
import { env } from '@/lib/core/config/env'
2223
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
24+
import {
25+
assertContentLengthWithinLimit,
26+
isPayloadSizeLimitError,
27+
readStreamToBufferWithLimit,
28+
} from '@/lib/core/utils/stream-limits'
2329
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2430
import { executeInboxTask } from '@/lib/mothership/inbox/executor'
2531
import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types'
@@ -29,9 +35,43 @@ const logger = createLogger('AgentMailWebhook')
2935
const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@']
3036
const MAX_EMAILS_PER_HOUR = 20
3137

38+
/**
39+
* Bound the unauthenticated AgentMail webhook body before buffering it for Svix
40+
* signature verification, so an oversized payload cannot exhaust pod memory.
41+
*/
42+
const AGENTMAIL_MAX_BODY_BYTES =
43+
Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024
44+
45+
const AGENTMAIL_BODY_LABEL = 'AgentMail webhook body'
46+
47+
async function readAgentMailBody(req: Request): Promise<string> {
48+
assertContentLengthWithinLimit(req.headers, AGENTMAIL_MAX_BODY_BYTES, AGENTMAIL_BODY_LABEL)
49+
const stream = req.body
50+
if (!stream) {
51+
return req.text()
52+
}
53+
const buffer = await readStreamToBufferWithLimit(stream, {
54+
maxBytes: AGENTMAIL_MAX_BODY_BYTES,
55+
label: AGENTMAIL_BODY_LABEL,
56+
})
57+
return new TextDecoder().decode(buffer)
58+
}
59+
3260
export const POST = withRouteHandler(async (req: Request) => {
3361
try {
34-
const rawBody = await req.text()
62+
let rawBody: string
63+
try {
64+
rawBody = await readAgentMailBody(req)
65+
} catch (bodyError) {
66+
if (isPayloadSizeLimitError(bodyError)) {
67+
logger.warn('Rejected oversized AgentMail webhook body', {
68+
maxBytes: AGENTMAIL_MAX_BODY_BYTES,
69+
observedBytes: bodyError.observedBytes,
70+
})
71+
return NextResponse.json({ error: 'Request body too large' }, { status: 413 })
72+
}
73+
throw bodyError
74+
}
3575
const headersResult = webhookSvixHeadersSchema.safeParse({
3676
'svix-id': req.headers.get('svix-id'),
3777
'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/processor.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscri
99
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'
12+
import { env } from '@/lib/core/config/env'
1213
import { isProd } from '@/lib/core/config/feature-flags'
14+
import {
15+
assertContentLengthWithinLimit,
16+
isPayloadSizeLimitError,
17+
readStreamToBufferWithLimit,
18+
} from '@/lib/core/utils/stream-limits'
1319
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
1420
import { preprocessExecution } from '@/lib/execution/preprocessing'
1521
import {
@@ -71,19 +77,47 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{
7177
return { valid: true }
7278
}
7379

80+
/**
81+
* Maximum size of a webhook request body read into memory. The webhook receiver
82+
* is public and unauthenticated, so the body must be bounded before it is
83+
* buffered to prevent a memory-exhaustion DoS. Provider payloads rarely exceed a
84+
* few MB; defaults to 10 MB and is overridable via `WEBHOOK_MAX_REQUEST_BYTES`.
85+
*/
86+
export const WEBHOOK_MAX_BODY_BYTES =
87+
Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024
88+
89+
const WEBHOOK_BODY_LABEL = 'Webhook request body'
90+
7491
export async function parseWebhookBody(
7592
request: NextRequest,
7693
requestId: string
7794
): Promise<{ body: unknown; rawBody: string } | NextResponse> {
7895
let rawBody: string | null = null
7996
try {
80-
const requestClone = request.clone()
81-
rawBody = await requestClone.text()
97+
assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, WEBHOOK_BODY_LABEL)
98+
99+
const stream = request.clone().body
100+
if (stream) {
101+
const buffer = await readStreamToBufferWithLimit(stream, {
102+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
103+
label: WEBHOOK_BODY_LABEL,
104+
})
105+
rawBody = new TextDecoder().decode(buffer)
106+
} else {
107+
rawBody = await request.clone().text()
108+
}
82109

83110
if (!rawBody || rawBody.length === 0) {
84111
return { body: {}, rawBody: '' }
85112
}
86113
} catch (bodyError) {
114+
if (isPayloadSizeLimitError(bodyError)) {
115+
logger.warn(`[${requestId}] Rejected oversized webhook body`, {
116+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
117+
observedBytes: bodyError.observedBytes,
118+
})
119+
return new NextResponse('Request body too large', { status: 413 })
120+
}
87121
logger.error(`[${requestId}] Failed to read request body`, {
88122
error: toError(bodyError).message,
89123
})

0 commit comments

Comments
 (0)