feat: server functions formdata guards#427
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | 7f4c05c | May 16 2026, 03:54 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 216.1K | 27.6K | 🟢 +682.8% |
| react: shallow wide (1000) | 2.1K | 359 | 🟢 +484.2% |
| react: deep nested (100) | 17.2K | 5.9K | 🟢 +189.7% |
| react: product list (50) | 6.0K | 1.9K | 🟢 +209.5% |
| react: large table (500x10) | 279 | 87 | 🟢 +219.4% |
| data: primitives | 171.8K | 38.6K | 🟢 +344.7% |
| data: large string (100KB) | 7.3K | 6.5K | 🟢 +11.5% |
| data: nested objects (20) | 57.6K | 24.4K | 🟢 +135.9% |
| data: large array (10K) | 116 | 112 | 🟢 +3.8% |
| data: Map & Set | 10.6K | 5.7K | 🟢 +87.6% |
| data: Date/BigInt/Symbol | 160.9K | 34.7K | 🟢 +364.2% |
| data: typed arrays | 32.9K | 12.5K | 🟢 +162.2% |
| data: mixed payload | 8.2K | 3.9K | 🟢 +112.3% |
Prerender (prerender)
| Scenario | @lazarv/rsc ops/s | mean |
|---|---|---|
| react: minimal element | 260.0K | 3.8 µs |
| react: shallow wide (1000) | 1.9K | 536.8 µs |
| react: deep nested (100) | 16.1K | 62.2 µs |
| react: product list (50) | 5.6K | 178.1 µs |
| react: large table (500x10) | 272 | 3.68 ms |
| data: primitives | 194.3K | 5.1 µs |
| data: large string (100KB) | 679 | 1.47 ms |
| data: nested objects (20) | 57.8K | 17.3 µs |
| data: large array (10K) | 115 | 8.68 ms |
| data: Map & Set | 11.2K | 89.2 µs |
| data: Date/BigInt/Symbol | 180.9K | 5.5 µs |
| data: typed arrays | 666 | 1.50 ms |
| data: mixed payload | 7.5K | 133.3 µs |
Deserialization (createFromReadableStream)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 169.0K | 137.9K | 🟢 +22.5% |
| react: shallow wide (1000) | 22.6K | 1.9K | 🟢 +1059.5% |
| react: deep nested (100) | 100.0K | 19.2K | 🟢 +421.7% |
| react: product list (50) | 51.2K | 14.5K | 🟢 +253.5% |
| react: large table (500x10) | 3.9K | 2.1K | 🟢 +89.0% |
| data: primitives | 135.9K | 125.9K | 🟢 +7.9% |
| data: large string (100KB) | 39.6K | 34.2K | 🟢 +15.8% |
| data: nested objects (20) | 77.9K | 68.9K | 🟢 +13.0% |
| data: large array (10K) | 280 | 242 | 🟢 +15.9% |
| data: Map & Set | 15.9K | 13.4K | 🟢 +18.8% |
| data: Date/BigInt/Symbol | 134.8K | 109.8K | 🟢 +22.8% |
| data: typed arrays | 52.4K | 42.6K | 🟢 +23.0% |
| data: mixed payload | 25.4K | 14.7K | 🟢 +73.1% |
Roundtrip (serialize + deserialize)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 98.4K | 20.0K | 🟢 +392.9% |
| react: shallow wide (1000) | 1.7K | 280 | 🟢 +491.5% |
| react: deep nested (100) | 14.4K | 4.2K | 🟢 +244.4% |
| react: product list (50) | 5.1K | 1.6K | 🟢 +225.8% |
| react: large table (500x10) | 258 | 85 | 🟢 +205.5% |
| data: primitives | 79.3K | 28.6K | 🟢 +177.9% |
| data: large string (100KB) | 6.0K | 6.5K | 🔴 -8.7% |
| data: nested objects (20) | 32.0K | 16.9K | 🟢 +89.0% |
| data: large array (10K) | 81 | 74 | 🟢 +9.4% |
| data: Map & Set | 6.1K | 3.8K | 🟢 +61.5% |
| data: Date/BigInt/Symbol | 65.2K | 22.0K | 🟢 +196.6% |
| data: typed arrays | 24.8K | 11.4K | 🟢 +117.5% |
| data: mixed payload | 5.9K | 2.8K | 🟢 +107.5% |
Legend & methodology
Indicators: 🟢 > 1% faster | 🔴 > 1% slower | ⚪ within noise margin
vs webpack: compares @lazarv/rsc against react-server-dom-webpack within the same run.
vs baseline: compares @lazarv/rsc against the previous main branch run.
Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup.
Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number.
❌ 3 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
⚡ Benchmark Results
Legend🟢 > 1% improvement | 🔴 > 1% regression | ⚪ within noise margin Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple routes are more meaningful than any single number. |
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 fromcreateFunctionitself; they sit on the HTTP middleware and run regardless of whether an action opted into validation.Body size cap
server.maxBodyBytesis a pre-parse cap on the raw request body, enforced before the WHATWGRequestis constructed and applied to every body-bearingPOST/PUT/PATCH/DELETEregardless of route or content-type. It defaults to0(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-Lengthgreater than the cap, the server responds413 Payload Too Largeimmediately and reads zero body bytes — the cheap path for honest clients with a declared length. WhenContent-Lengthis missing or lying (chunked transfer, attacker-controlled headers), bytes are counted as they arrive through a wrappingTransform; 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'shighWaterMark(~16 KiB) regardless of the rejected payload's declared size; time is bounded by the HTTP server'srequestTimeout. The cap is independent of, and runs before, the per-decode limits inserverFunctions.limits.*— those still apply afterwards inside the decoder.Multipart per-part caps
server.maxBodyBytesbounds 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 1MFormDataentries 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 withoutfilename=is treated as a string field by the platform parser, bypassing any downstreamfile()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.mjsand switches the parser based on configuration. When any sub-limit is set to a positive value, multipart bodies are parsed viabusboy(configured withdefParamCharset: "utf8"to matchundici'sRequest.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 parsedFormDatais functionally equivalent to what the platform parser would produce (filename, MIME, size, bytes preserved); onlyContent-Transfer-Encodingper part diverges, and since the HTML5 spec dropped it formultipart/form-dataand modern browsers never emit it, this affects nothing in practice. An A/B equivalence test inhttp-multipart-cap.spec.mjsasserts the property. The cap only applies on the NodecreateMiddlewarepath — 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 customreact-server-actionheader — 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 amultipart/form-databody 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.csrfvalidates the request'sOrigin(orReferer) 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, plusserver.origin, plusserver.cors.origin/originswhen configured with explicit values (CORS-trusted partners are usually CSRF-trusted too), plusserver.csrf.allowedOriginsfor 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 HTTP403 Forbiddenwithx-react-server-action-error: csrf_origin_mismatch(orcsrf_origin_missingin 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 inserver.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. Theexamples/remoteruntime 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, noreact-server-actionheader) and callscheckCsrf(context.request, config)beforedecodeReplyruns. A rejection throwsCsrfRejectedError, which is caught alongside the existingDecodeValidationErrorbranch 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 theDecodeValidationErrorpath: the priorif (getContext(HTTP_HEADERS)) setshape 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 touchedHTTP_HEADERS. The new code mirrors the canonical setter pattern fromserver/http-headers.mjs— create-on-demand and write back viacontext$.File-upload integration
createFunction'sformData()/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.mjsplus the matching fixtures) that drives uploads through a real browser, the multipart wire, the WHATWGRequest, the platform / busboy parser path, and finally thecreateFunctiondecode. 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-finitemaxFileSizeand verifying overflow rejects with 413 before the file fully buffers.Action-token decode perf hardening
decryptActionTokenruns on every action-shapedPOST, 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.