## 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.
Summary
This PR closes the third and final piece of the server-function security series. PR #421 made the action token tamper-evident; PR #422 added the kill-switch for apps that don't use server functions at all. What was still missing — and what this lands — is a way to declare what shape an action expects from the wire, so the runtime can reject malformed payloads at the protocol layer instead of letting them reach handler code where intent is lost.
The API is
createFunction, exported from a new@lazarv/react-server/functionsubpath. It wraps a"use server"handler with a per-arg parse/validate spec, the bundler forwards that spec toregisterServerReference, and the protocol decoder consults it on every call. Bad inputs are caught during decode and the request fails withHTTP 400and anx-react-server-action-error: <reason>header before any handler code runs. Bare"use server"actions withoutcreateFunctionkeep working unchanged — validation is opt-in and additive.The API
The most common shape is the array shorthand:
createFunction([z.string(), z.number()])(handler). The slot index is the runtime arg slot — what the client puts on the wire at positioni— not the handler signature param. When you also need pre-validate parsing, the object form takes both arrays explicitly:createFunction({ parse: [...], validate: [...] })(handler). The no-spec formcreateFunction()(handler)exists too; it attaches the marker so the dev-strict warning treats the export as deliberately unvalidated. Bound captures (closure values from.bind(...)or render-time closures) are explicitly not part of the validation contract — they're integrity-protected by the AEAD action token from #421, not validated as user inputs.The full TypeScript story comes for free with this API. The handler's parameter types are inferred from the schemas via the same
ValidateSchema<T>/InferSchema<T>machinery the typed router already uses, so any Standard Schema (Zod, Valibot, ArkType, generic.parse()) works as a slot constraint. Hovering anaddEntrycall site that was declared withz.object({ name: z.string() })shows(input: { name: string }) => Promise<…>— derived directly from the schema, no manual type annotation. Misuse at the call site is a TypeScript error, not a runtime surprise.Wire-aware helpers
A Standard Schema isn't enough for every Flight wire type. Some validations need to bound resource consumption before the handler observes the value (file uploads, byte buffers); some need to wrap an async source so the bound is enforced as the handler consumes (streams, async iterables); some need a constructor allowlist that's narrowed in TypeScript via
instanceofrather than a string-name lookup (typed arrays). For each of those cases there's a dedicated wire-aware helper.formData(shape, options?)declares a sub-FormData with declared-key entries (no prefix scan, an attacker-injected5_role=adminis rejected by default), and inside itfile({ maxBytes, mime })andblob(...)enforce per-entry size and MIME synchronously againstBlob.size/Blob.type.arrayBuffer({ maxBytes })caps byte length on$AB,typedArray({ ctor: Float32Array, maxBytes })does the same for$ATwhile narrowing the inferred handler type to the exactFloat32Arrayinstance.map({ maxSize, key, value })andset({ maxSize, value })cap collection size and route inner key/value validation through the same Standard Schema bridge.stream({ maxChunks, maxBytes })covers both the text ($r) and binary ($b) Flight stream tags by wrapping the materializedReadableStreamin aTransformStreamthat errors instead of yielding past the cap.asyncIterable({ maxYields, value })anditerable(...)do the same for$xand$X, with each yielded value flowing through the inner schema as the handler pulls.promise(value)wraps$@so the resolved value runs through the schema before reaching the handler. There's also anoopexport — an identity sentinel that reads as intent at the call site when only some slots need validation, so users don't have to write sparse-array literals or bareundefined.Decoder integration and error semantics
In
@lazarv/rsc,registerServerReferencegained an optional fourthmetaargument and a pairedlookupServerFunctionMetafor hosts to query at decode time.decodeReply's options grewactionId,resolveServerFunctionMeta, andvalidateArghooks; when all three are present the decoder switches from the legacy whole-tree walk to the new slot-walk inwalkArgsWithMeta, which applies parse → validate slot-by-slot and aborts on the first failure with a newDecodeValidationError. The error carries the failingargIndex, the recoveredactionId, a coarsereasoncode (validate_failed,parse_failed,unknown_entry,max_bytes_exceeded,max_size_exceeded,max_chunks_exceeded,max_yields_exceeded,mime_not_allowed,wire_shape_mismatch,missing_entry,duplicate_entry,custom_validate_failed,max_bound_args_exceeded), and the underlying schema diagnostic inoriginal. The legacy$hpath inshared.mjsgot a parallel structural defense-in-depth pass: when an action has registered meta and the encrypted token already delivered bound captures, any non-empty wire-suppliedparsed.boundis rejected as a wire-shape mismatch — the trusted channel for closure captures is the AEAD-protected token, not the wire'sboundfield.Dispatcher and dev guardrail
In
render-rsc.jsx, the action-call dispatch now pre-resolves the action id (header decrypt or$ACTION_ID_*form-field scan) beforedecodeReply, then preloads the action's source module viarequireModuleso the meta registry is populated by the time the slot-walk asks for it. Without this preload, every action's first invocation would silently skip validation because the registry is filled by the module's top-levelregisterServerReferencecalls, which run only after import. Validation failures map to HTTP 400 with the reason inx-react-server-action-error. Schema diagnostics deliberately don't travel to the client — they can leak expected-shape details that aid attackers — but they're written to the server log vialogger.warnfor operator visibility. There's also a dev-only guardrail: each unwrapped"use server"action logs a one-time warning the first time it's called, naming the action in the same<modulePath>#<exportName>form the registry keys on, in a styled message that distinguishes file paths (gray italic) from JS code (magenta) and import specifiers (cyan). Setconfig.serverFunctions.strict = falseto silence it during incremental migrations.