Public: anyone can read this; the crew can change it.

No request has the forge read an unbounded body #762

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

Closes #698

shipped production is serving it for issue #698 — its issue is already done deploy d5b2fe2

opened this

Closes #698

No request a stranger can make has the forge read an unbounded body. Settled by measuring Bun 1.3 first; the facts that shaped this:

  • maxRequestBodySize binds only a declared length. A chunked body passed a 1 KiB limit at 5 KB. The listener's ceiling is still set deliberately (512 MiB, the largest git push), but every reader counts the stream itself.
  • Bun's own fetch reuses a connection even when the answer says Connection: close. With a reader that just stopped (or cancelled), the next request on that connection was read out of the leftover body and answered with a bare 400, as #705 found. So past a cap the reader drains at most 1 MiB more and drops it, which keeps the connection framed: the next request is read as itself. A body past even that budget is abuse; reading stops and the connection is closed once answered (server.timeout, plus Connection: close). A proxy that could not finish writing a body never reuses its connection.
  • A body no route reads, of no declared length (an anonymous push answered 401, a redirect), was drained by Bun for as long as the client kept sending. It is now drained within the same budget, or closed.

What changed

  • src/http/limits.ts: the caps in one place, readBounded (counts, stops, drains or closes), settleBody for unread bodies, counted for streams. The server arms a per-request close, so a refusal found mid-stream (a push past its cap after git started answering) still ends the connection.
  • Stranger routes at 64 KiB, each answering 413: POST /api/v1/users, /api/v1/sessions, /api/v1/tokens, /oauth/register, and the signin, signup and report forms. Reports and MCP already used the bounded reader. The shared body() and readForm() are now bounded too (16 MiB); readForm parses the kept bytes, urlencoded or multipart.
  • Git smart-HTTP:

- A push's pack streams to git counted against 512 MiB, and a fetch's negotiation against 16 MiB. - A compressed fetch was read whole and inflated with no limit. It is now read within 1 MiB and inflated with maxOutputLength. A streaming DecompressionStream was tried and dropped: when it is cut off, Bun's adapter throws an uncaught error into the process, and Bun exits on one. - Writes to git's stdin are now awaited, and they stop once git has exited. Before this, an un-awaited write under backpressure left a loose EPIPE whenever git quit early. That was harmless as it stood, but the same class of error is fatal once it escapes.

  • docs/ARCHITECTURE.md names the caps (concurrency invariant 6).

Tests (test/body-limits.test.ts, in the gate, 20):

  • A declared oversize body and an undeclared (chunked) oversize body are both refused with 413 on all seven stranger routes.
  • A request pipelined behind a refused chunked body is answered as itself.
  • Bun's fetch reusing the connection after a refusal gets the right answer.
  • An endless chunked body is cut off long before the idle timeout, on a route that reads it and on one that does not (an anonymous push).
  • A 64 MiB gzip bomb on an anonymous fetch stops at its cap and the forge stays up.
  • A 3 MiB authenticated push still works.

Also run: git, reports, report-discovery, oauth-discovery, mcp, web, web-editor, web-ui and edge-auth, all green. (test/settings.test.ts fails on main from #675's reserved "owner"; fixed in #759.)

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs