Skip to content

Commit 806c8b0

Browse files
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

docs/src/pages/en/(pages)/features/http-layer.mdx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default {
2323
headersTimeout: 66000,
2424
requestTimeout: 30000,
2525
maxConcurrentRequests: 100,
26+
maxBodyBytes: 32 * 1024 * 1024,
2627
shutdownTimeout: 25000,
2728
},
2829
};
@@ -34,8 +35,121 @@ export default {
3435
| `headersTimeout` | `66000` | Maximum time (ms) to wait for the client to send the full request headers. Must exceed `keepAliveTimeout`. |
3536
| `requestTimeout` | `30000` | Maximum time (ms) for the client to send the complete request (headers + body). Set to `0` to disable. |
3637
| `maxConcurrentRequests` | `0` | Maximum number of concurrent requests before the server responds with `503 Service Busy`. Set to `0` to disable admission control. |
38+
| `maxBodyBytes` | `0` (disabled) | Pre-parse cap on the raw request body in bytes. Enforced before the WHATWG `Request` is constructed. Set to a positive value (e.g. `32 * 1024 * 1024`) to apply the cap directly in the runtime. |
3739
| `shutdownTimeout` | `25000` | After receiving `SIGTERM`/`SIGINT`, the server stops accepting new connections and waits up to this duration (ms) for in-flight requests to complete before force-exiting. Should be less than your k8s `terminationGracePeriodSeconds` (default 30s). |
3840

41+
<Link name="body-size-cap">
42+
## Body size cap
43+
</Link>
44+
45+
The body cap defaults to `0` (disabled) — most production deployments terminate body limits at a reverse proxy, CDN, or platform edge, and a second runtime-level cap doesn't add defence in depth in that topology. Set `maxBodyBytes` to a positive value when you want the runtime itself to apply the cap, typically when running without a proxy in front (single-host deployments, local-only services, or as a belt-and-braces setting alongside an upstream limit).
46+
47+
When the cap is active, oversized request bodies are rejected at the HTTP layer before any handler sees the request. Two paths handle the cap:
48+
49+
1. **Declared `Content-Length` check.** If the client sent a `Content-Length` greater than the cap, the server responds `413 Payload Too Large` immediately and reads zero body bytes. This is the cheap path — honest clients with a declared length bail here, with a clean response status.
50+
2. **Streaming counter during read.** Handles missing or lying `Content-Length` (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 status — this is the trade-off for not reading the rest of the attacker-controlled payload just to deliver a courtesy status code.
51+
52+
The cap applies to every body-bearing `POST` / `PUT` / `PATCH` / `DELETE` regardless of route or content-type. It is independent of, and runs before, the per-decode limits in `serverFunctions.limits.*` — those still apply afterwards inside the Server Function decoder.
53+
54+
Memory peak is bounded by the wrapping stream's `highWaterMark` (~16 KiB) regardless of the rejected payload's size — the wrapper observes bytes as they flow but never buffers them. Time is bounded by the HTTP server's `requestTimeout` (default 30s, configurable via `server.requestTimeout`).
55+
56+
<Link name="multipart-cap">
57+
## Multipart per-part caps
58+
</Link>
59+
60+
`server.maxBodyBytes` bounds total wire bytes but cannot defend against attacks that fit inside any reasonable body cap:
61+
62+
- **High-cardinality**: 1M small fields × 32 bytes each is only ~32 MiB on the wire, but the platform's multipart parser still allocates 1M `FormData` entries plus per-entry strings.
63+
- **Long field names**: a single field with a 1 MiB name has small wire bytes but allocates a 1 MiB string in the parser.
64+
- **File-as-field smuggling**: a large blob without `filename=` is treated as a string field by the parser, bypassing any downstream `file()` size policy.
65+
66+
`server.multipart.*` lets you cap per-part shape during streaming parse. When *any* sub-limit is set to a positive value, multipart requests are parsed via `busboy` (instead of the platform `Request.formData()`), enforcing the configured limits as bytes flow. Overflow on any limit rejects with HTTP 413 *before* the offending part is fully buffered.
67+
68+
```mjs filename="react-server.config.mjs"
69+
export default {
70+
server: {
71+
multipart: {
72+
maxFileSize: 10 * 1024 * 1024, // 10 MiB per file
73+
maxFieldSize: 1 * 1024 * 1024, // 1 MiB per text field
74+
maxFiles: 10, // up to 10 files per request
75+
maxFields: 100, // up to 100 text fields per request
76+
maxParts: 200, // 200 total parts (files + fields)
77+
maxFieldNameSize: 200, // 200 bytes per field name
78+
},
79+
},
80+
};
81+
```
82+
83+
| Limit | Defends against |
84+
|---|---|
85+
| `maxFileSize` | Oversized file uploads, even within a generous body cap |
86+
| `maxFieldSize` | File-as-field smuggling; oversized text values |
87+
| `maxFiles` | Many-file submissions allocating many `File` wrappers |
88+
| `maxFields` | High-cardinality field attacks |
89+
| `maxParts` | Total entries cap (files + fields combined) |
90+
| `maxFieldNameSize` | Long-field-name string allocation attacks |
91+
92+
All sub-limits default to `0` (disabled). When *every* sub-limit is disabled, busboy is never invoked and multipart bodies pass through to the platform parser unchanged — zero overhead.
93+
94+
The parsed `FormData` is functionally equivalent to what the platform parser would have produced (filename, MIME type, size, and bytes are preserved). Only `Content-Transfer-Encoding` per part diverges — the HTML5 spec dropped it for `multipart/form-data` and modern browsers never emit it, so this affects nothing in practice. An A/B equivalence test in the integration suite asserts the property.
95+
96+
The cap applies on every adapter target the runtime ships. The Node path consumes the raw incoming request directly with busboy; the edge / serverless path adapts the Web `Request` body to the same parser via Node's Web Streams interop. Per-part cap semantics are identical on both paths because they share the same parser core. The body cap (`server.maxBodyBytes`) is similarly portable — declared `Content-Length` is checked from the headers, then the body is read up to `maxBodyBytes + 1` and rejected with 413 immediately if it overflows. On native-edge runtimes without Node-compatibility APIs, the per-part multipart cap silently downgrades to the platform parser; the body cap continues to apply.
97+
98+
<Link name="csrf">
99+
## CSRF / Origin validation
100+
</Link>
101+
102+
`server.csrf` defends server-function action POSTs against Cross-Site Request Forgery by validating the request's `Origin` (or `Referer`) header against a trusted-origin set.
103+
104+
The threat is narrower than it first looks. JS-driven action calls — `fetch()` with the custom `react-server-action` header — are already safe: any 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 **form-submit action POSTs**: `<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.
105+
106+
```mjs filename="react-server.config.mjs"
107+
export default {
108+
server: {
109+
csrf: {
110+
mode: "lax", // default
111+
allowedOrigins: [
112+
"https://host.example.com",
113+
/^https:\/\/[^.]+\.partner\.com$/,
114+
],
115+
},
116+
},
117+
};
118+
```
119+
120+
| Mode | Origin / Referer missing | Origin present & trusted | Origin present & untrusted |
121+
|---|---|---|---|
122+
| `"lax"` (default) | allow | allow | **403** |
123+
| `"strict"` | **403** | allow | **403** |
124+
| `false` / `"off"` | allow | allow | allow |
125+
126+
The **trusted-origin set** is built implicitly from your existing config:
127+
128+
1. The request's own resolved origin (proxy-aware), so same-origin form posts always work without configuration
129+
2. `server.origin` — the canonical configured identity
130+
3. `server.cors.origin` / `origins` when configured with explicit values (not `*`/`true`) — CORS-trusted partners are usually CSRF-trusted too
131+
4. `server.csrf.allowedOrigins` — explicit additions for cases where CSRF trust differs from CORS trust
132+
133+
**Remote components: the case that needs explicit configuration.** When a host app embeds remote components from this app, the user's browser sees forms whose action targets the remote (this app). 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. This is by design — the remote operator must explicitly declare which host origins may invoke their action endpoints.
134+
135+
```mjs filename="remote-app/react-server.runtime.config.mjs"
136+
export default {
137+
server: {
138+
cors: true,
139+
csrf: {
140+
allowedOrigins: [
141+
"https://host.example.com",
142+
"https://staging-host.example.com",
143+
],
144+
},
145+
},
146+
};
147+
```
148+
149+
**Rejection response:** HTTP `403 Forbidden` with header `x-react-server-action-error: csrf_origin_mismatch` (or `csrf_origin_missing` in strict mode without an Origin). The handler never runs and the body is never parsed.
150+
151+
**Out of scope for this feature:** token-based CSRF (double-submit cookie / per-session nonce). That's a stricter defence appropriate for high-value actions, but it requires session awareness that the runtime can't synthesize on your behalf. Apps that need it can implement it as a middleware in front of the action-dispatch.
152+
39153
<Link name="keep-alive">
40154
## Keep-alive and timeouts
41155
</Link>

docs/src/pages/en/(pages)/features/server-function-limits.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Because the limits are enforced inside the decoder, they cover both:
3939
| `maxStringLength` | `16 MiB` | Length of a single string row before decoding |
4040
| `maxStreamChunks` | `10000` | Chunks materialised for a decoded `ReadableStream`, `AsyncIterable`, or `Iterator` |
4141

42+
These ceilings run *during* decoding. The wire-level ceiling on the raw request body is enforced separately by the HTTP server config — see [`server.maxBodyBytes`](/features/http-layer#body-size-cap) for the pre-parse cap that protects against memory DoS before any of these decoder-level checks even run.
43+
4244
Each limit is independent — overriding one does not reset the others to their defaults.
4345

4446
<Link name="configuration">

0 commit comments

Comments
 (0)