fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with small, named limits for Better Auth and Core's grant-mutation endpoint, and a new required maxBodyBytes field on the executable module route contract so every module route makes a conscious no-body-or-bounded-body choice, capped at a platform ceiling. Every oversized request gets one sanitized 413 contract and never reaches business logic, authorization, or a handler. Also configure finite Node header/request/keep-alive timeouts, including the non-obvious connectionsCheckingInterval needed for them to take effect promptly, so a slow-header or slow-body connection cannot hold a socket indefinitely. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cafd513019
commit
d3c6bd73d3
@@ -278,6 +278,50 @@ bucket, and unrelated real peers are never collapsed into one shared bucket mere
|
||||
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
|
||||
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
|
||||
|
||||
## Request body limits
|
||||
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||||
declared-length-free streamed body without ever buffering the whole payload first):
|
||||
|
||||
| Surface | Limit |
|
||||
| --- | --- |
|
||||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
|
||||
An oversized request always receives the identical stable, sanitized contract —
|
||||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||||
Auth's business logic, a Core mutation, permission evaluation (where avoidable), a module handler,
|
||||
or module database behavior. For a state-changing route the body limit is enforced after
|
||||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||||
is never read for size at all, while an oversized same-origin request still fails before any
|
||||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
||||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
||||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
||||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||||
|
||||
## Node request and header timeouts
|
||||
|
||||
`apps/api/src/server.ts` configures the underlying Node HTTP server (via `@hono/node-server`'s
|
||||
`serverOptions`, passed straight through to `http.createServer`) with finite
|
||||
`headersTimeout` (20s), `requestTimeout` (30s), and `keepAliveTimeout` (5s, Node's own default,
|
||||
made explicit). Node's own documented behavior responds `408` and closes the connection without
|
||||
ever invoking a request listener when either timeout is exceeded, so a timed-out connection cannot
|
||||
invoke a handler or receive a second response by construction.
|
||||
|
||||
One non-obvious detail, found only by reproducing it directly against the pinned Node runtime:
|
||||
Node's `http.Server` only *checks* for a timeout violation when its periodic
|
||||
`connectionsCheckingInterval` fires, and that option's own default is 30 seconds — leaving it
|
||||
unset would silently let a violating connection sit for up to a further 30 seconds past its
|
||||
configured timeout. `connectionsCheckingIntervalMs` is therefore also set explicitly (5s). A
|
||||
sanitized, fixed operational log line (never the remote address, headers, or request path) is
|
||||
emitted on a server-level `'timeout'` event purely for observability; it does not participate in,
|
||||
and cannot suppress, Node's own `408`/close behavior. See
|
||||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) for the full
|
||||
account, including the standalone reproduction that found the `connectionsCheckingInterval` gap.
|
||||
|
||||
## Module discovery, permission composition, and module runtime startup
|
||||
|
||||
`startApi()` composes production in one fixed order:
|
||||
@@ -330,6 +374,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
|
||||
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||||
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||||
- **Body limit.** A route's own `maxBodyBytes` (see [Module development](module-development/README.md#http-routes)
|
||||
and [Request body limits](#request-body-limits)) is enforced immediately after the trusted-origin
|
||||
check and before permission evaluation — `0` means no body is accepted at all. An oversized
|
||||
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
|
||||
authorization, the handler, or the module database.
|
||||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
|
||||
body all behave normally, and the full query string remains available) plus a frozen context of
|
||||
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
|
||||
|
||||
Reference in New Issue
Block a user