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;
## 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
/apiroute except/jmapand/upload/now takes at most 64 KB (Hono'sbodyLimit)./auth/loginread 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./upload.maxConcurrentRequests; more get a 429.docker-compose.yml.127.0.0.1only, which matchesdeploy.example.shand the README's "put a reverse proxy in front".TRUST_PROXY=1, any peer in a private range could set its ownX-Forwarded-For.read_onlywith a/tmptmpfs,cap_drop: ALLandno-new-privileges..env.*is ignored by git and excluded from the Docker build context, except.env.example.deploy.example.shkeepsAPP_SECRETin.env.production, whichCOPY . .would otherwise have copied into the build stage.Not changed: which
X-Forwarded-Forentry 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 newrequest-limits.test.ts. It covers:GatedBudgetErrorkeeps its class throughResponse.text(), which is what turns a spent budget into a 503 rather than a 413.npm run typecheckandnpm run build -w serverare clean.docker compose up --buildwith the new file:127.0.0.1:8080only;/datais writable and/appis read-only (EROFS);CapEffis 0;Merged 2026-09-16 as coffey-labs/ihasmail@47a2477d9f
Rebuilt from: git history, session transcript.