Skip to content

feat: server functions formdata guards#427

Merged
lazarv merged 3 commits into
mainfrom
feat/server-functions-formdata-guards
May 16, 2026
Merged

feat: server functions formdata guards#427
lazarv merged 3 commits into
mainfrom
feat/server-functions-formdata-guards

Conversation

@lazarv

@lazarv lazarv commented May 16, 2026

Copy link
Copy Markdown
Owner

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
react-server-docs 7f4c05c May 16 2026, 03:54 PM

@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown

⚡ Flight Protocol Benchmark

Commit: 7f5f141

Serialization (renderToReadableStream)

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.

@codecov-commenter

codecov-commenter commented May 16, 2026

Copy link
Copy Markdown

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
1120 3 1117 4
View the top 3 failed test(s) by shortest run time
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: get-started-ts > starts in production mode
Stack Traces | 0.000739s run time
AssertionError: production start should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:74:66
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: router > starts in production mode
Stack Traces | 0.00105s run time
AssertionError: production start should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:74:66
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: get-started-ts > builds the app
Stack Traces | 0.00128s run time
AssertionError: build should succeed: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:70:58
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: router > builds the app
Stack Traces | 0.00517s run time
AssertionError: build should succeed: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:70:58
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: get-started-ts > dev mode starts and serves the app
Stack Traces | 0.00873s run time
AssertionError: dev mode should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:66:56

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown

⚡ Benchmark Results

PR 7f4c05c main 49ec76b
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1239 ⚪ +0.1% 39.75 ms ⚪ +0.2% 72 ms 0.8 MB/s
small 1271 🟢 +1.7% 38.73 ms 🟢 -1.7% 66 ms 1.3 MB/s
medium 384 🟢 +2.7% 128.65 ms 🟢 -2.6% 184 ms 5.7 MB/s
large 47 🟢 +4.6% 980.87 ms 🟢 -4.4% 1927 ms 4.8 MB/s
deep 854 🔴 -5.0% 57.78 ms 🔴 +5.7% 95 ms 2.9 MB/s
wide 67 🔴 -5.7% 710.03 ms 🔴 +3.5% 1359 ms 3.7 MB/s
cached 3342 🔴 -1.6% 14.43 ms 🔴 +1.3% 29 ms 49.1 MB/s
client-min 454 🟢 +1.3% 109.21 ms 🟢 -1.2% 173 ms 1.9 MB/s
client-small 452 🔴 -3.2% 109.17 ms 🔴 +3.0% 162 ms 2.1 MB/s
client-med 343 ⚪ -0.6% 144.49 ms ⚪ +0.6% 219 ms 6.3 MB/s
client-large 84 🟢 +1.2% 576.47 ms 🟢 -3.0% 1080 ms 8.8 MB/s
client-deep 437 ⚪ +0.0% 113.3 ms ⚪ +0.2% 168 ms 3.1 MB/s
client-wide 143 ⚪ +0.8% 345.88 ms ⚪ -0.6% 614 ms 8.3 MB/s
rsc-client-large 1066 🔴 -1.9% 46.27 ms 🔴 +2.0% 68 ms 2.7 MB/s
rsc-client-wide 1101 ⚪ +0.6% 44.77 ms ⚪ -0.6% 66 ms 2.8 MB/s
static-json 6897 🔴 -7.7% 6.72 ms 🔴 +10.0% 16 ms 2.9 MB/s
static-js 7203 🟢 +4.6% 6.31 ms 🟢 -7.3% 17 ms 9.0 MB/s
404-miss 4738 ⚪ +0.7% 10.06 ms ⚪ +0.5% 22 ms 0.6 MB/s
hybrid-min 440 🔴 -2.0% 112.18 ms 🔴 +2.6% 178 ms 2.1 MB/s
hybrid-small 433 ⚪ +0.1% 113.62 ms ⚪ -0.5% 167 ms 2.5 MB/s
hybrid-medium 236 🟢 +2.3% 209.77 ms 🟢 -1.3% 298 ms 10.0 MB/s
hybrid-large 42 🟢 +4.7% 1118.78 ms 🟢 -1.7% 1996 ms 13.4 MB/s
hybrid-deep 356 ⚪ +0.3% 139.04 ms ⚪ +0.0% 201 ms 4.9 MB/s
hybrid-wide 63 🟢 +4.3% 774.12 ms ⚪ -0.9% 1461 ms 12.4 MB/s
hybrid-cached 2938 🟢 +2.5% 16.46 ms 🟢 -2.5% 30 ms 124.8 MB/s
hybrid-client-min 477 🔴 -1.2% 104.03 ms 🔴 +1.4% 155 ms 2.1 MB/s
hybrid-client-small 460 🔴 -5.9% 107.59 ms 🔴 +6.0% 159 ms 2.2 MB/s
hybrid-client-medium 350 🔴 -2.5% 141.62 ms 🔴 +3.2% 216 ms 6.5 MB/s
hybrid-client-large 84 🟢 +4.3% 576.53 ms 🟢 -1.4% 1095 ms 8.8 MB/s
hybrid-client-deep 434 🟢 +2.1% 113.55 ms 🟢 -1.9% 171 ms 3.1 MB/s
hybrid-client-wide 139 🟢 +2.6% 352.93 ms ⚪ -0.9% 662 ms 8.2 MB/s
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.

@lazarv lazarv merged commit 806c8b0 into main May 16, 2026
75 of 77 checks passed
@lazarv lazarv deleted the feat/server-functions-formdata-guards branch May 16, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants