fix(webhooks): cap request body size on public webhook receivers#5075
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@greptile |
|
@cursor review |
PR SummaryMedium Risk Overview A shared
The AgentMail webhook route gets the same pattern via Reviewed by Cursor Bugbot for commit 5748e23. Configure here. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 7a3918d. Configure here.
Greptile SummaryThis PR caps the request body size on public, unauthenticated webhook receivers to defend against memory-exhaustion DoS. It reuses the existing
Confidence Score: 5/5Safe to merge — the change adds a purely defensive body cap to two unauthenticated endpoints with no functional behaviour change for well-formed requests. All changed paths reuse already-tested utilities; the null-body edge case is handled correctly by readStreamToBufferWithLimit returning an empty buffer; the shared constant eliminates the drift risk flagged in prior review; no unbounded fallback paths remain. No files require special attention. Important Files Changed
Reviews (5): Last reviewed commit: "chore(webhooks): drop inline comments fr..." | Re-trigger Greptile |
Greptile SummaryThis PR caps the request body size on the two public, unauthenticated webhook receivers (
Confidence Score: 4/5Safe to merge — the fix correctly closes the DoS vector on both webhook routes and reuses battle-tested stream-limit utilities; the two remaining gaps are edge cases unreachable under any current runtime. Both webhook paths now have a content-length pre-check and a streamed byte cap, which is the correct fix. The two findings are the null-body fallback (which calls
Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant C as Caller
participant WH as POST /api/webhooks/trigger/[path]
participant AM as POST /api/webhooks/agentmail
participant SL as stream-limits utils
C->>WH: POST (arbitrary body)
WH->>SL: assertContentLengthWithinLimit(headers, 10 MB)
alt "content-length > 10 MB"
SL-->>WH: throws PayloadSizeLimitError
WH-->>C: 413 Request body too large
else no content-length / within limit
WH->>SL: readStreamToBufferWithLimit(stream, 10 MB)
alt "stream bytes > 10 MB"
SL-->>WH: throws PayloadSizeLimitError
WH-->>C: 413 Request body too large
else within limit
SL-->>WH: Buffer
WH->>WH: decode → rawBody → parseWebhookBody → execute
WH-->>C: 200 Webhook processed
end
end
C->>AM: POST (Svix webhook)
AM->>SL: assertContentLengthWithinLimit(headers, 10 MB)
alt "content-length > 10 MB"
SL-->>AM: throws PayloadSizeLimitError
AM-->>C: 413 Request body too large
else
AM->>SL: readStreamToBufferWithLimit(req.body, 10 MB)
alt "stream bytes > 10 MB"
SL-->>AM: throws PayloadSizeLimitError
AM-->>C: 413 Request body too large
else within limit
SL-->>AM: Buffer → rawBody
AM->>AM: Svix wh.verify(rawBody, headers)
AM-->>C: 200 ok
end
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant C as Caller
participant WH as POST /api/webhooks/trigger/[path]
participant AM as POST /api/webhooks/agentmail
participant SL as stream-limits utils
C->>WH: POST (arbitrary body)
WH->>SL: assertContentLengthWithinLimit(headers, 10 MB)
alt "content-length > 10 MB"
SL-->>WH: throws PayloadSizeLimitError
WH-->>C: 413 Request body too large
else no content-length / within limit
WH->>SL: readStreamToBufferWithLimit(stream, 10 MB)
alt "stream bytes > 10 MB"
SL-->>WH: throws PayloadSizeLimitError
WH-->>C: 413 Request body too large
else within limit
SL-->>WH: Buffer
WH->>WH: decode → rawBody → parseWebhookBody → execute
WH-->>C: 200 Webhook processed
end
end
C->>AM: POST (Svix webhook)
AM->>SL: assertContentLengthWithinLimit(headers, 10 MB)
alt "content-length > 10 MB"
SL-->>AM: throws PayloadSizeLimitError
AM-->>C: 413 Request body too large
else
AM->>SL: readStreamToBufferWithLimit(req.body, 10 MB)
alt "stream bytes > 10 MB"
SL-->>AM: throws PayloadSizeLimitError
AM-->>C: 413 Request body too large
else within limit
SL-->>AM: Buffer → rawBody
AM->>AM: Svix wh.verify(rawBody, headers)
AM-->>C: 200 ok
end
end
|
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.
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit c40c6d7. Configure here.
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.
|
Addressed the remaining note from the last pass: collapsed the |
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit e84b0c3. Configure here.
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5748e23. Configure here.
Summary
parseWebhookBody(used byPOST /api/webhooks/trigger/[path]) now bounds the body via the existing size-limited stream reader — content-length guard + streamed cap (assertContentLengthWithinLimit+readStreamToBufferWithLimit) — and returns413on oversize, mirroring the hardenedreadJsonBodyWithLimitpath already used by contract routes.WEBHOOK_MAX_REQUEST_BYTES, following the existingCHAT_MAX_REQUEST_BYTESprecedent.Type of Change
Testing
stream-limitsutilities.bunx tsc --noEmitclean,bun run check:api-validationpasses, existing webhook trigger route tests (22) pass.Checklist