Bound what a request can make the server hold #377

Closed
opened 2026-09-16 14:54:44 +00:00 by jcoffey-dev · 0 comments
Owner

Summary

These are the server-side fixes from a review of request handling. Each one bounds how much memory or trust a single client can take.

  • Small JSON bodies.
    • Every /api route except /jmap and /upload/ now takes at most 64 KB (Hono's bodyLimit).
    • Before this, /auth/login read a body of any size before its rate limits ran. A few unauthenticated requests carrying hundreds of megabytes could run the process out of memory, and a restart signs everyone out.
    • The sign-in flood limit now also runs before the body is read.
  • Checked JMAP requests. This covers sessions that may not administer, whose request body is read and checked before forwarding.
    • The size cap drops from 16 MB to 4 MB. The client sends nothing close: attachments and inline images go through /upload.
    • At most 4 checked requests can be in flight per session, matching Stalwart's default maxConcurrentRequests; more get a 429.
    • Across all sessions, at most 32 MB can be held at once, counted as the bytes arrive; past that, requests get a 503. A slow body that has sent little holds little.
    • Before this, any signed-in user could send parallel 16 MB bodies, each held as a string, parsed and serialized again.
  • IPv6 sign-in limits count per /64. A host that holds one address holds 2^64 of them, so a limit keyed on the full address didn't limit anything. Session records still keep the full address.
  • docker-compose.yml.
    • It now publishes on 127.0.0.1 only, which matches deploy.example.sh and the README's "put a reverse proxy in front".
    • Before this, the app was reachable over plain HTTP on every interface, where passwords travel unencrypted. And with TRUST_PROXY=1, any peer in a private range could set its own X-Forwarded-For.
    • It also runs read_only with a /tmp tmpfs, cap_drop: ALL and no-new-privileges.
  • .env.* is ignored by git and excluded from the Docker build context, except .env.example. deploy.example.sh keeps APP_SECRET in .env.production, which COPY . . would otherwise have copied into the build stage.

Not changed: which X-Forwarded-For entry becomes the client. By default the code walks past every private address, which is what lets two private proxy hops work with no configuration (there's a test for it). The cost is that a LAN client behind a single proxy can pick its own rate-limit key. Changing that is a trade-off between those two kinds of setup, so it's left for a separate decision.

Related issues

None.

Translations

Adds none.

Testing

  • npm test -w server: 236 tests pass, including the new request-limits.test.ts. It covers:
    • a sign-in body refused by its declared length, and by counting a chunked body;
    • other JSON routes being limited too;
    • a 200 KB checked JMAP request still going through, and a 5 MB one being refused;
    • a fifth concurrent checked read getting a 429, and the slots coming back afterwards;
    • /64 keys.
  • Checked separately that a GatedBudgetError keeps its class through Response.text(), which is what turns a spent budget into a 503 rather than a 413.
  • npm run typecheck and npm run build -w server are clean.
  • Built and ran docker compose up --build with the new file:
    • the container went healthy and was bound to 127.0.0.1:8080 only;
    • /data is writable and /app is read-only (EROFS);
    • CapEff is 0;
    • a live 300 KB sign-in request got a 413.

Merged 2026-09-16 as coffey-labs/ihasmail@47a2477d9f

Rebuilt from: git history, session transcript.

## Summary These are the server-side fixes from a review of request handling. Each one bounds how much memory or trust a single client can take. - **Small JSON bodies.** - Every `/api` route except `/jmap` and `/upload/` now takes at most 64 KB (Hono's `bodyLimit`). - Before this, `/auth/login` read a body of any size before its rate limits ran. A few unauthenticated requests carrying hundreds of megabytes could run the process out of memory, and a restart signs everyone out. - The sign-in flood limit now also runs before the body is read. - **Checked JMAP requests.** This covers sessions that may not administer, whose request body is read and checked before forwarding. - The size cap drops from 16 MB to 4 MB. The client sends nothing close: attachments and inline images go through `/upload`. - At most 4 checked requests can be in flight per session, matching Stalwart's default `maxConcurrentRequests`; more get a 429. - Across all sessions, at most 32 MB can be held at once, counted as the bytes arrive; past that, requests get a 503. A slow body that has sent little holds little. - Before this, any signed-in user could send parallel 16 MB bodies, each held as a string, parsed and serialized again. - **IPv6 sign-in limits count per /64.** A host that holds one address holds 2^64 of them, so a limit keyed on the full address didn't limit anything. Session records still keep the full address. - **`docker-compose.yml`.** - It now publishes on `127.0.0.1` only, which matches `deploy.example.sh` and the README's "put a reverse proxy in front". - Before this, the app was reachable over plain HTTP on every interface, where passwords travel unencrypted. And with `TRUST_PROXY=1`, any peer in a private range could set its own `X-Forwarded-For`. - It also runs `read_only` with a `/tmp` tmpfs, `cap_drop: ALL` and `no-new-privileges`. - **`.env.*` is ignored** by git and excluded from the Docker build context, except `.env.example`. `deploy.example.sh` keeps `APP_SECRET` in `.env.production`, which `COPY . .` would otherwise have copied into the build stage. **Not changed: which `X-Forwarded-For` entry becomes the client.** By default the code walks past every private address, which is what lets two private proxy hops work with no configuration (there's a test for it). The cost is that a LAN client behind a single proxy can pick its own rate-limit key. Changing that is a trade-off between those two kinds of setup, so it's left for a separate decision. ## Related issues None. ## Translations Adds none. ## Testing - `npm test -w server`: 236 tests pass, including the new `request-limits.test.ts`. It covers: - a sign-in body refused by its declared length, and by counting a chunked body; - other JSON routes being limited too; - a 200 KB checked JMAP request still going through, and a 5 MB one being refused; - a fifth concurrent checked read getting a 429, and the slots coming back afterwards; - /64 keys. - Checked separately that a `GatedBudgetError` keeps its class through `Response.text()`, which is what turns a spent budget into a 503 rather than a 413. - `npm run typecheck` and `npm run build -w server` are clean. - Built and ran `docker compose up --build` with the new file: - the container went healthy and was bound to `127.0.0.1:8080` only; - `/data` is writable and `/app` is read-only (`EROFS`); - `CapEff` is 0; - a live 300 KB sign-in request got a 413. **Merged** 2026-09-16 as coffey-labs/ihasmail@47a2477d9f4e <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.