Skip to content

fix(webhooks): cap request body size on public webhook receivers#5075

Merged
waleedlatif1 merged 4 commits into
stagingfrom
fix/webhook-body-size-limit
Jun 16, 2026
Merged

fix(webhooks): cap request body size on public webhook receivers#5075
waleedlatif1 merged 4 commits into
stagingfrom
fix/webhook-body-size-limit

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Public, unauthenticated webhook receivers read the entire request body into memory before any webhook lookup or signature verification, so a caller could send arbitrarily large (or many concurrent large) bodies to exhaust pod memory and OOM-kill the server.
  • parseWebhookBody (used by POST /api/webhooks/trigger/[path]) now bounds the body via the existing size-limited stream reader — content-length guard + streamed cap (assertContentLengthWithinLimit + readStreamToBufferWithLimit) — and returns 413 on oversize, mirroring the hardened readJsonBodyWithLimit path already used by contract routes.
  • Applied the same cap to the AgentMail webhook route before its Svix signature read.
  • Cap defaults to 10 MB (provider payloads rarely exceed a few MB), overridable via WEBHOOK_MAX_REQUEST_BYTES, following the existing CHAT_MAX_REQUEST_BYTES precedent.

Type of Change

  • Bug fix (security — DoS hardening)

Testing

  • Tested manually; reuses already-tested stream-limits utilities.
  • bunx tsc --noEmit clean, bun run check:api-validation passes, existing webhook trigger route tests (22) pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

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.
@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 15, 2026 11:53pm

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor

cursor Bot commented Jun 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches unauthenticated ingress paths and changes rejection behavior (413) for oversized bodies; low functional risk for normal provider payloads but operators may need to tune WEBHOOK_MAX_REQUEST_BYTES if a provider sends larger events.

Overview
Hardens public, unauthenticated webhook receivers against memory-exhaustion DoS by bounding how much of the request body is read into memory before lookup or signature verification.

A shared WEBHOOK_MAX_BODY_BYTES limit (default 10 MB, overridable via WEBHOOK_MAX_REQUEST_BYTES) is introduced in lib/webhooks/constants.ts, wired through env config alongside existing body-size knobs like CHAT_MAX_REQUEST_BYTES.

parseWebhookBody (workflow trigger webhooks) no longer uses unbounded request.clone().text(); it applies assertContentLengthWithinLimit plus readStreamToBufferWithLimit and returns 413 when the cap is exceeded.

The AgentMail webhook route gets the same pattern via readAgentMailBody before Svix verification, with structured warn logging on reject.

Reviewed by Cursor Bugbot for commit 5748e23. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR caps the request body size on public, unauthenticated webhook receivers to defend against memory-exhaustion DoS. It reuses the existing stream-limits utilities (assertContentLengthWithinLimit + readStreamToBufferWithLimit) that already guard the JSON contract routes, returning HTTP 413 on oversize instead of buffering arbitrary bodies.

  • Adds a WEBHOOK_MAX_REQUEST_BYTES env var (default 10 MB) and extracts a shared WEBHOOK_MAX_BODY_BYTES constant into a new lib/webhooks/constants.ts, giving both the trigger receiver (processor.ts) and the AgentMail route a single source of truth for the cap.
  • Replaces unbounded request.clone().text() / req.text() calls with the size-limited stream reader; empty bodies (null stream) are correctly handled by readStreamToBufferWithLimit returning Buffer.alloc(0) and short-circuiting to { body: {}, rawBody: '' }.

Confidence Score: 5/5

Safe 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

Filename Overview
apps/sim/lib/webhooks/processor.ts parseWebhookBody now applies content-length pre-check and stream cap before buffering; 413 path is correctly wired through the existing catch block
apps/sim/app/api/webhooks/agentmail/route.ts readAgentMailBody helper bounds the body before Svix signature verification; uses WEBHOOK_MAX_BODY_BYTES from shared constants, no local duplication
apps/sim/lib/webhooks/constants.ts New file: single source of truth for the webhook body cap, parses WEBHOOK_MAX_REQUEST_BYTES with correct fallback
apps/sim/lib/core/config/env.ts Adds WEBHOOK_MAX_REQUEST_BYTES env var with 10 MB default, consistent with CHAT_MAX_REQUEST_BYTES pattern

Reviews (5): Last reviewed commit: "chore(webhooks): drop inline comments fr..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR caps the request body size on the two public, unauthenticated webhook receivers (POST /api/webhooks/trigger/[path] and the AgentMail Svix route) to prevent memory-exhaustion DoS. It reuses the existing assertContentLengthWithinLimit + readStreamToBufferWithLimit primitives from stream-limits.ts and wires in a new WEBHOOK_MAX_REQUEST_BYTES env variable (default 10 MB).

  • parseWebhookBody in processor.ts now checks the content-length header and streams the body through a capped reader, returning 413 on overflow.
  • agentmail/route.ts gains a readAgentMailBody helper with the same two-layer guard before the Svix signature read.
  • A WEBHOOK_MAX_REQUEST_BYTES env var (default '10485760') is added to env.ts, matching the pattern of CHAT_MAX_REQUEST_BYTES.

Confidence Score: 4/5

Safe 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 .text() uncapped but is only reached for genuinely empty bodies) and a duplicated constant that could drift. Neither affects the security goal of this PR, but both would benefit from a small follow-up.

processor.ts and agentmail/route.ts — specifically the else branches that fall through to uncapped .text() calls and the duplicated max-bytes constant.

Important Files Changed

Filename Overview
apps/sim/lib/webhooks/processor.ts Adds content-length pre-check and streamed size cap to parseWebhookBody; the null-stream fallback still calls request.clone().text() uncapped, and creates a redundant second clone.
apps/sim/app/api/webhooks/agentmail/route.ts Adds readAgentMailBody helper with content-length guard and streamed cap before Svix verification; null-stream fallback to req.text() is unbounded, and the max-bytes constant duplicates the one already exported from processor.ts.
apps/sim/lib/core/config/env.ts Adds WEBHOOK_MAX_REQUEST_BYTES env var with a 10 MB default, following the existing CHAT_MAX_REQUEST_BYTES precedent — straightforward and correct.

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
Loading
%%{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
Loading

Comments Outside Diff (3)

  1. apps/sim/app/api/webhooks/agentmail/route.ts, line 47-58 (link)

    Unbounded req.text() fallback when body stream is null

    When req.body is null, the function skips the stream-based size cap and calls req.text() with no limit. The assertContentLengthWithinLimit guard only fires when a content-length header is present; a chunked-transfer request with no content-length and a runtime that happens to return null for req.body would bypass both guards. Since readStreamToBufferWithLimit already handles a null stream by returning an empty Buffer, the if (!stream) branch can be removed entirely to close the gap without changing behaviour for genuinely empty bodies.

  2. apps/sim/lib/webhooks/processor.ts, line 98-108 (link)

    Same unbounded fallback in parseWebhookBody and unnecessary second clone

    The else branch calls request.clone().text() — a second, uncapped Request.clone() — when request.clone().body is null. Because readStreamToBufferWithLimit already returns Buffer.alloc(0) for a null stream, the entire if/else can collapse to a single call. Additionally, this branch creates two clones (request.clone().body and request.clone().text()); saving the clone once eliminates the redundant allocation.

  3. apps/sim/app/api/webhooks/agentmail/route.ts, line 42-43 (link)

    Duplicated constant parsing

    AGENTMAIL_MAX_BODY_BYTES re-implements the same Number.parseInt(env.WEBHOOK_MAX_REQUEST_BYTES, 10) || 10 * 1024 * 1024 expression already present as the exported WEBHOOK_MAX_BODY_BYTES in processor.ts. Importing the already-exported constant would give both paths a single source of truth and remove the risk of the two values drifting if the default ever changes.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "fix(webhooks): cap request body size on ..." | Re-trigger Greptile

Comment thread apps/sim/app/api/webhooks/agentmail/route.ts Outdated
Comment thread apps/sim/app/api/webhooks/agentmail/route.ts Outdated
Comment thread apps/sim/lib/webhooks/processor.ts Outdated
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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining note from the last pass: collapsed the if (!stream) null-body branch in both capped readers (parseWebhookBody and readAgentMailBody) into a single readStreamToBufferWithLimit call. It already returns an empty buffer for a null stream, so the uncapped .text() fallback (a theoretical chunked/no-content-length bypass) is gone and both paths are consistent. Fixed in e84b0c3.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@waleedlatif1 waleedlatif1 merged commit a09e393 into staging Jun 16, 2026
13 checks passed
@waleedlatif1 waleedlatif1 deleted the fix/webhook-body-size-limit branch June 16, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant