Commit 806c8b0
authored
feat: server functions formdata guards (#427)
## Summary
This PR extends the server-function security work with the
*transport-layer* defences that wrap around the per-arg validation that
landed in #424. #421 hardened the action token, #422 added the
kill-switch for apps with no `"use server"` exports, #424 gave the
decoder a per-slot validation contract. What was still missing — and
what this lands — is a set of defences that fire *before* a request ever
reaches the decoder: a raw body size cap, per-part multipart caps for
the FormData / file-upload shape, and Origin-based CSRF rejection for
the one action-call shape that CORS doesn't already cover. None of these
defences are reachable from `createFunction` itself; they sit on the
HTTP middleware and run regardless of whether an action opted into
validation.
## Body size cap
`server.maxBodyBytes` is a pre-parse cap on the raw request body,
enforced before the WHATWG `Request` is constructed and applied to every
body-bearing `POST` / `PUT` / `PATCH` / `DELETE` regardless of route or
content-type. It defaults to `0` (disabled) — most production
deployments terminate body limits at a CDN / proxy / platform edge, and
a second runtime-level cap doesn't add defence in depth in that
topology. Set it to a positive value when running without a proxy in
front (single-host deployments, local-only services) or as a
belt-and-braces alongside an upstream limit.
Two paths handle the cap. When the client sends a `Content-Length`
greater than the cap, the server responds `413 Payload Too Large`
immediately and reads zero body bytes — the cheap path for honest
clients with a declared length. When `Content-Length` is missing or
lying (chunked transfer, attacker-controlled headers), bytes are counted
as they arrive through a wrapping `Transform`; on overflow the
underlying socket is destroyed immediately to bound resource usage. The
connection close surfaces on the client side as a socket-level error
rather than a 413 — the deliberate trade for not draining the rest of an
attacker-controlled payload just to deliver a courtesy status code.
Memory peak is bounded by the wrapping stream's `highWaterMark` (~16
KiB) regardless of the rejected payload's declared size; time is bounded
by the HTTP server's `requestTimeout`. The cap is independent of, and
runs before, the per-decode limits in `serverFunctions.limits.*` — those
still apply afterwards inside the decoder.
## Multipart per-part caps
`server.maxBodyBytes` bounds total wire bytes but cannot defend against
attacks that fit inside any reasonable body cap: 1M small fields × 32
bytes each is only ~32 MiB on the wire but allocates 1M `FormData`
entries plus per-entry strings; a single field with a 1 MiB *name* has
small wire bytes but allocates a 1 MiB string in the parser; a large
blob without `filename=` is treated as a string field by the platform
parser, bypassing any downstream `file()` size policy.
`server.multipart.*` adds per-part caps that fire during the streaming
parse rather than after materialisation: `maxFileSize`, `maxFieldSize`,
`maxFiles`, `maxFields`, `maxParts`, `maxFieldNameSize`. Overflow on any
limit rejects with HTTP 413 *before* the offending part is fully
buffered.
The implementation lives in `lib/http/multipart-cap.mjs` and switches
the parser based on configuration. When *any* sub-limit is set to a
positive value, multipart bodies are parsed via `busboy` (configured
with `defParamCharset: "utf8"` to match `undici`'s `Request.formData()`
behaviour around filename encoding); when every sub-limit is disabled,
busboy is never invoked and bodies pass through to the platform parser
unchanged — zero overhead. The parsed `FormData` is functionally
equivalent to what the platform parser would produce (filename, MIME,
size, bytes preserved); only `Content-Transfer-Encoding` per part
diverges, and since the HTML5 spec dropped it for `multipart/form-data`
and modern browsers never emit it, this affects nothing in practice. An
A/B equivalence test in `http-multipart-cap.spec.mjs` asserts the
property. The cap only applies on the Node `createMiddleware` path —
edge / serverless adapters have their own platform-level multipart
limits and are not affected.
## CSRF / Origin validation
The threat surface for CSRF on server functions is narrower than it
first looks. JS-driven action calls — `fetch()` with the custom
`react-server-action` header — are already safe: the custom header makes
the request not CORS-simple, so the browser preflights it and the
runtime refuses unsolicited cross-origin preflights. What needs explicit
defence is the **form-submit shape**: `<form method="POST">` with a
`multipart/form-data` body and a `$ACTION_ID_<token>` field. That shape
is CORS-simple — browsers send it without preflight — so a malicious
site can submit such a form cross-origin unless the receiving app
validates the source.
`server.csrf` validates the request's `Origin` (or `Referer`) against a
trusted-origin set. The set is built implicitly from existing config:
the request's own resolved origin (proxy-aware) so same-origin posts
always work without configuration, plus `server.origin`, plus
`server.cors.origin`/`origins` when configured with explicit values
(CORS-trusted partners are usually CSRF-trusted too), plus
`server.csrf.allowedOrigins` for cases where CSRF trust differs from
CORS trust. Mode `"lax"` (the default) allows missing-Origin requests
(some non-browser clients and older proxies don't send it); `"strict"`
rejects them; `false`/`"off"` disables the check entirely. Rejections
return HTTP `403 Forbidden` with `x-react-server-action-error:
csrf_origin_mismatch` (or `csrf_origin_missing` in strict mode); the
handler never runs and the body is never parsed.
The case that actually needs explicit configuration is **remote
components**. When a host app embeds remote components from another app,
the user's browser sees forms whose action targets the remote, so on
submit the browser POSTs cross-origin to the remote with `Origin: <host
origin>`. Without an entry in `server.csrf.allowedOrigins`, the remote
rejects the legitimate form submit with 403 — by design, since the
remote operator must explicitly declare which host origins may invoke
their action endpoints. The `examples/remote` runtime config has been
updated with the canonical pattern (local-dev hosts pre-populated,
production hosts as commented-out template) and inline rationale so
adopters don't have to reverse-engineer the policy. Token-based CSRF
(double-submit cookie / per-session nonce) is deliberately out of scope
here — it requires session awareness the runtime can't synthesise on the
app's behalf, and apps that need it can layer it as middleware in front
of the action-dispatch.
Wiring lives in `render-rsc.jsx`: the action-dispatch block detects the
form-submit shape (multipart body, no `react-server-action` header) and
calls `checkCsrf(context.request, config)` *before* `decodeReply` runs.
A rejection throws `CsrfRejectedError`, which is caught alongside the
existing `DecodeValidationError` branch and mapped to 403 with a
warn-level server log. Origin details are deliberately not echoed in the
response body; clients only need the reason header. The same catch block
also fixes a latent bug in the `DecodeValidationError` path: the prior
`if (getContext(HTTP_HEADERS)) set` shape silently dropped the error
header when the context hadn't been initialised yet, which could happen
when the catch fired before any other code path had touched
`HTTP_HEADERS`. The new code mirrors the canonical setter pattern from
`server/http-headers.mjs` — create-on-demand and write back via
`context$`.
## File-upload integration
`createFunction`'s `formData()` / `file()` helpers shipped in #424 but
the tests at the time covered them in isolation. This PR adds a full
end-to-end spec (`test/__test__/file-upload.spec.mjs` plus the matching
fixtures) that drives uploads through a real browser, the multipart
wire, the WHATWG `Request`, the platform / busboy parser path, and
finally the `createFunction` decode. Each action computes a SHA-256 of
the bytes server-side and returns the digest; the spec recomputes the
digest from the same byte source it sent and asserts equality, proving
the bytes survived the full round-trip intact through every parser
configuration. The fixture also exercises the multipart cap by
configuring a generous-but-finite `maxFileSize` and verifying overflow
rejects with 413 before the file fully buffers.
## Action-token decode perf hardening
`decryptActionToken` runs on every action-shaped `POST`, and under
sustained attacker traffic AES-GCM auth-tag verification (even when it
fails) is several orders of magnitude more expensive than a charset or
length check. Two cheap pre-filters were added: minimum encoded length
(38 chars, the structural minimum for a base64url-encoded 12-byte IV +
16-byte auth tag) and a base64url charset regex. Garbage tokens now bail
in microseconds without any base64 decode or AES setup. The base64
decode itself was also hoisted out of the per-key loop — without that
hoist, the decode ran N times for N rotation keys on every request,
wasted work that grew linearly with rotation depth. The bounds match the
wire format's absolute structural minimum so the pre-filter never
rejects a token the cipher itself would accept.1 parent 49ec76b commit 806c8b0
36 files changed
Lines changed: 3151 additions & 146 deletions
File tree
- docs/src/pages
- en/(pages)/features
- ja/(pages)/features
- examples/remote
- packages/react-server
- adapters
- bun/server
- deno/server
- docker/server
- config
- lib
- dev
- http
- start
- server
- test
- __test__
- fixtures
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| 26 | + | |
26 | 27 | | |
27 | 28 | | |
28 | 29 | | |
| |||
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| 38 | + | |
37 | 39 | | |
38 | 40 | | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
39 | 153 | | |
40 | 154 | | |
41 | 155 | | |
| |||
Lines changed: 2 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
| 42 | + | |
| 43 | + | |
42 | 44 | | |
43 | 45 | | |
44 | 46 | | |
| |||
0 commit comments