Skip to content

fix(server): cap every request body to prevent a JSON/MCP OOM#118

Merged
benvinegar merged 2 commits into
mainfrom
fix/cap-request-body-size
Jun 24, 2026
Merged

fix(server): cap every request body to prevent a JSON/MCP OOM#118
benvinegar merged 2 commits into
mainfrom
fix/cap-request-body-size

Conversation

@benvinegar

@benvinegar benvinegar commented Jun 24, 2026

Copy link
Copy Markdown
Member

What & why

The previous PR (#117) bounded POST /api/assets, but that was only the binary half of the same vector. Every other write endpoint/api/surfaces, /api/snippets, /api/comments, /api/sessions, /api/sessions/:id/trace, /api/theme — and /mcp still read their body with an unbounded c.req.json(). So the same unauthenticated out-of-memory flood was still reachable — an attacker just POSTs a multi-gigabyte JSON body to /api/surfaces (or /mcp). The local default ships with no auth token, so on any board reachable beyond localhost these endpoints are open. (On Cloudflare Workers the platform caps request bodies ~100 MB by plan; the Node self-hosted path has no such backstop — so this matters most there.)

Fix

Use Hono's built-in bodyLimit everywhere, in two scopes:

// global cap on every JSON/MCP body
const limitBody = bodyLimit({ maxSize: MAX_BODY_BYTES, onError: (c) => c.json({ error: "request body too large" }, 413) });
app.use("*", (c, next) => (c.req.path === "/api/assets" ? next() : limitBody(c, next)));

// the asset route's own, tighter cap (keeps the asset limit + wording)
const limitAssetBody = bodyLimit({ maxSize: MAX_ASSET_BYTES, onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413) });
app.post("/api/assets", limitAssetBody, async (c) => { const buf = new Uint8Array(await c.req.arrayBuffer()); /* ... */ });
  • MAX_BODY_BYTES = 16 MiB global — clears the largest legitimate body (a base64-encoded asset over MCP, ~4/3 of the 5 MiB asset cap) while bounding a flood.
  • Streaming-safe: bodyLimit short-circuits on an oversize Content-Length and otherwise aborts the stream at the cap, so a chunked body (no Content-Length) can't slip past — the gap fix(server): cap the asset-upload body while streaming to prevent an OOM #117 closed for assets.
  • After auth: an unauthenticated request on a token board is refused (401) before its body is read; on a no-token board the cap still bounds it.
  • /api/assets is exempt from the global cap and instead carries its own bodyLimit at the (stricter) asset limit, so the tighter bound is the only one that applies.

Also folds in the asset route onto the primitive

This PR additionally replaces #117's hand-rolled readBodyCapped (a custom streaming reader + manual Content-Length check) with the scoped bodyLimit above — same behavior and limit, one framework primitive instead of a bespoke reader (net −37 lines). Done here rather than as a follow-up so we don't merge the custom reader only to delete it.

Tests

test/api.test.ts adds the global body cap rejects oversize JSON and MCP bodies (over-cap Content-Length → 413 on a REST write endpoint and /mcp). The asset route's existing tests from #117 — oversize Content-Length, chunked-stream cap (asserts the read stops early), and multi-chunk reassembly — all still pass against the scoped bodyLimit, confirming the swap preserved behavior. The streaming-abort path itself is Hono's bodyLimit (tested upstream).

  • npm run typecheck, npm run lint, npm run format:check, npm test (205/205) ✅

🤖 Generated with Claude Code

benvinegar and others added 2 commits June 23, 2026 21:38
The asset-upload fix bounded /api/assets, but every other write endpoint
(/api/surfaces, /api/comments, /api/sessions, the trace ingest, /api/theme) and
/mcp still read their body with an unbounded c.req.json() — so the same
unauthenticated out-of-memory vector was reachable by POSTing a giant JSON body
instead. The local default has no auth token, so those endpoints are reachable
unauthenticated.

Add a global bodyLimit that 413s any request body over a generous ceiling
(sized to clear a base64 asset over MCP). It short-circuits on an oversize
Content-Length and otherwise aborts the stream at the cap, so a chunked body
can't slip past. It runs after auth — unauthenticated requests on a token board
are refused before their body is read — and exempts /api/assets, which streams
its own stricter cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dyCapped

The asset route had a hand-rolled streaming cap (readBodyCapped) plus a manual
Content-Length check. Hono's bodyLimit does both — short-circuits an oversize
Content-Length and aborts a chunked stream at the cap — so scope one to the
route (at the asset limit, with the asset-specific 413 message) and read the
now-bounded body with arrayBuffer(). Same behavior and limit, one primitive
instead of a bespoke reader. /api/assets stays exempt from the global body cap
so its tighter limit is the only one that applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@benvinegar benvinegar merged commit eb269b5 into main Jun 24, 2026
9 checks passed
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