From bc81b47f9d56786c68d19648415820a0f68124f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 16 May 2026 15:01:44 +0200 Subject: [PATCH 1/2] feat: server functions formdata guards --- .../pages/en/(pages)/features/http-layer.mdx | 114 ++++++ .../features/server-function-limits.mdx | 2 + .../pages/ja/(pages)/features/http-layer.mdx | 114 ++++++ .../features/server-function-limits.mdx | 2 + .../remote/react-server.runtime.config.mjs | 37 +- packages/react-server/config/schema.d.ts | 179 +++++++++ packages/react-server/config/schema.mjs | 89 +++++ packages/react-server/config/validate.mjs | 55 +++ .../react-server/lib/dev/create-server.mjs | 2 + packages/react-server/lib/http/middleware.mjs | 368 +++++++++++++++++- .../react-server/lib/http/multipart-cap.mjs | 277 +++++++++++++ .../react-server/lib/start/create-server.mjs | 2 + packages/react-server/package.json | 1 + .../react-server/server/action-crypto.mjs | 55 ++- packages/react-server/server/csrf.mjs | 199 ++++++++++ packages/react-server/server/render-rsc.jsx | 73 +++- pnpm-lock.yaml | 3 + test/__test__/csrf.spec.mjs | 169 ++++++++ test/__test__/file-upload.spec.mjs | 235 +++++++++++ test/__test__/http-body-cap.spec.mjs | 140 +++++++ test/__test__/http-multipart-cap.spec.mjs | 244 ++++++++++++ .../server-function-validation.spec.mjs | 115 +----- test/fixtures/body-cap.jsx | 35 ++ test/fixtures/csrf-action.jsx | 28 ++ test/fixtures/csrf-actions.mjs | 17 + test/fixtures/file-upload-actions.mjs | 111 ++++++ test/fixtures/file-upload-client.jsx | 128 ++++++ test/fixtures/file-upload.jsx | 10 + test/fixtures/multipart-cap.jsx | 58 +++ 29 files changed, 2730 insertions(+), 132 deletions(-) create mode 100644 packages/react-server/lib/http/multipart-cap.mjs create mode 100644 packages/react-server/server/csrf.mjs create mode 100644 test/__test__/csrf.spec.mjs create mode 100644 test/__test__/file-upload.spec.mjs create mode 100644 test/__test__/http-body-cap.spec.mjs create mode 100644 test/__test__/http-multipart-cap.spec.mjs create mode 100644 test/fixtures/body-cap.jsx create mode 100644 test/fixtures/csrf-action.jsx create mode 100644 test/fixtures/csrf-actions.mjs create mode 100644 test/fixtures/file-upload-actions.mjs create mode 100644 test/fixtures/file-upload-client.jsx create mode 100644 test/fixtures/file-upload.jsx create mode 100644 test/fixtures/multipart-cap.jsx diff --git a/docs/src/pages/en/(pages)/features/http-layer.mdx b/docs/src/pages/en/(pages)/features/http-layer.mdx index ceabcd76..86a1f565 100644 --- a/docs/src/pages/en/(pages)/features/http-layer.mdx +++ b/docs/src/pages/en/(pages)/features/http-layer.mdx @@ -23,6 +23,7 @@ export default { headersTimeout: 66000, requestTimeout: 30000, maxConcurrentRequests: 100, + maxBodyBytes: 32 * 1024 * 1024, shutdownTimeout: 25000, }, }; @@ -34,8 +35,121 @@ export default { | `headersTimeout` | `66000` | Maximum time (ms) to wait for the client to send the full request headers. Must exceed `keepAliveTimeout`. | | `requestTimeout` | `30000` | Maximum time (ms) for the client to send the complete request (headers + body). Set to `0` to disable. | | `maxConcurrentRequests` | `0` | Maximum number of concurrent requests before the server responds with `503 Service Busy`. Set to `0` to disable admission control. | +| `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. | | `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). | + +## Body size cap + + +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). + +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: + +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. +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. + +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. + +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`). + + +## Multipart per-part caps + + +`server.maxBodyBytes` bounds total wire bytes but cannot defend against attacks that fit inside any reasonable body cap: + +- **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. +- **Long field names**: a single field with a 1 MiB name has small wire bytes but allocates a 1 MiB string in the parser. +- **File-as-field smuggling**: a large blob without `filename=` is treated as a string field by the parser, bypassing any downstream `file()` size policy. + +`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. + +```mjs filename="react-server.config.mjs" +export default { + server: { + multipart: { + maxFileSize: 10 * 1024 * 1024, // 10 MiB per file + maxFieldSize: 1 * 1024 * 1024, // 1 MiB per text field + maxFiles: 10, // up to 10 files per request + maxFields: 100, // up to 100 text fields per request + maxParts: 200, // 200 total parts (files + fields) + maxFieldNameSize: 200, // 200 bytes per field name + }, + }, +}; +``` + +| Limit | Defends against | +|---|---| +| `maxFileSize` | Oversized file uploads, even within a generous body cap | +| `maxFieldSize` | File-as-field smuggling; oversized text values | +| `maxFiles` | Many-file submissions allocating many `File` wrappers | +| `maxFields` | High-cardinality field attacks | +| `maxParts` | Total entries cap (files + fields combined) | +| `maxFieldNameSize` | Long-field-name string allocation attacks | + +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. + +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. + +The cap only applies on the Node `createMiddleware` path. Edge / serverless adapters (Cloudflare Workers, Vercel Functions, etc.) have their own platform-level multipart limits and are not affected by this config. + + +## CSRF / Origin validation + + +`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. + +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**: `
` with a `multipart/form-data` body and a `$ACTION_ID_` 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. + +```mjs filename="react-server.config.mjs" +export default { + server: { + csrf: { + mode: "lax", // default + allowedOrigins: [ + "https://host.example.com", + /^https:\/\/[^.]+\.partner\.com$/, + ], + }, + }, +}; +``` + +| Mode | Origin / Referer missing | Origin present & trusted | Origin present & untrusted | +|---|---|---|---| +| `"lax"` (default) | allow | allow | **403** | +| `"strict"` | **403** | allow | **403** | +| `false` / `"off"` | allow | allow | allow | + +The **trusted-origin set** is built implicitly from your existing config: + +1. The request's own resolved origin (proxy-aware), so same-origin form posts always work without configuration +2. `server.origin` — the canonical configured identity +3. `server.cors.origin` / `origins` when configured with explicit values (not `*`/`true`) — CORS-trusted partners are usually CSRF-trusted too +4. `server.csrf.allowedOrigins` — explicit additions for cases where CSRF trust differs from CORS trust + +**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: `. 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. + +```mjs filename="remote-app/react-server.runtime.config.mjs" +export default { + server: { + cors: true, + csrf: { + allowedOrigins: [ + "https://host.example.com", + "https://staging-host.example.com", + ], + }, + }, +}; +``` + +**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. + +**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. + ## Keep-alive and timeouts diff --git a/docs/src/pages/en/(pages)/features/server-function-limits.mdx b/docs/src/pages/en/(pages)/features/server-function-limits.mdx index 4e00cd18..1d6918f0 100644 --- a/docs/src/pages/en/(pages)/features/server-function-limits.mdx +++ b/docs/src/pages/en/(pages)/features/server-function-limits.mdx @@ -39,6 +39,8 @@ Because the limits are enforced inside the decoder, they cover both: | `maxStringLength` | `16 MiB` | Length of a single string row before decoding | | `maxStreamChunks` | `10000` | Chunks materialised for a decoded `ReadableStream`, `AsyncIterable`, or `Iterator` | +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. + Each limit is independent — overriding one does not reset the others to their defaults. diff --git a/docs/src/pages/ja/(pages)/features/http-layer.mdx b/docs/src/pages/ja/(pages)/features/http-layer.mdx index 263c6d39..c4d27885 100644 --- a/docs/src/pages/ja/(pages)/features/http-layer.mdx +++ b/docs/src/pages/ja/(pages)/features/http-layer.mdx @@ -23,6 +23,7 @@ export default { headersTimeout: 66000, requestTimeout: 30000, maxConcurrentRequests: 100, + maxBodyBytes: 32 * 1024 * 1024, shutdownTimeout: 25000, }, }; @@ -34,8 +35,121 @@ export default { | `headersTimeout` | `66000` | クライアントが完全なリクエストヘッダーを送信するまでの最大待機時間(ミリ秒)。`keepAliveTimeout`を超える値に設定してください。 | | `requestTimeout` | `30000` | クライアントが完全なリクエスト(ヘッダー+ボディ)を送信するまでの最大時間(ミリ秒)。`0`に設定すると無効になります。 | | `maxConcurrentRequests` | `0` | サーバーが`503 Service Busy`を返すまでの最大同時リクエスト数。`0`に設定するとアドミッション制御が無効になります。 | +| `maxBodyBytes` | `0`(無効) | 生のリクエストボディに対するパース前の上限(バイト数)。WHATWG `Request`が構築される前に強制されます。ランタイムで上限を直接適用したい場合は正の値(例:`32 * 1024 * 1024`)に設定してください。 | | `shutdownTimeout` | `25000` | `SIGTERM`/`SIGINT`を受信後、サーバーは新しいコネクションの受け入れを停止し、処理中のリクエストが完了するまでこの時間(ミリ秒)待機してから強制終了します。k8sの`terminationGracePeriodSeconds`(デフォルト30秒)より短く設定してください。 | + +## ボディサイズ上限 + + +ボディサイズ上限はデフォルトで`0`(無効)です。本番環境ではリバースプロキシ、CDN、またはプラットフォームのエッジでボディ上限を終端することが多く、その構成ではランタイムレベルの上限を二重に持たせる意味は薄いためです。プロキシなしで実行する場合(単一ホストのデプロイ、ローカル限定サービスなど)、またはアップストリーム上限と並行して多重防御として、`maxBodyBytes`を正の値に設定するとランタイム自身が上限を適用します。 + +上限が有効な場合、サイズ超過のリクエストボディはハンドラがリクエストを目にする前にHTTP層で拒否されます。上限処理には2つのパスがあります: + +1. **宣言された`Content-Length`のチェック。** クライアントが`Content-Length`を上限より大きく宣言した場合、サーバはボディを1バイトも読まずに即座に`413 Payload Too Large`を返します。これが安価なパスです。長さを宣言する正直なクライアントはここで止まり、クリーンなレスポンスステータスを受け取ります。 +2. **読み込み中のストリーミングカウンタ。** 欠落または偽装された`Content-Length`(chunked transfer、攻撃者が制御するヘッダ)を処理します。バイト数はラップする`Transform`を通過する際にカウントされ、上限超過時にはリソース使用量を制限するため即座にソケットが破棄されます。接続クローズはクライアント側ではソケットレベルのエラーとして現れ、413ステータスは返されません。これは、丁寧なステータスコードを返すためだけに攻撃者が制御するペイロードの残りを読み取らないというトレードオフです。 + +上限はルートやcontent-typeにかかわらず、すべてのボディを持つ`POST` / `PUT` / `PATCH` / `DELETE`に適用されます。`serverFunctions.limits.*`内のデコード単位の上限とは独立して、それより前に実行されます。これらはServer Functionデコーダ内で後から適用されます。 + +メモリピークは拒否されたペイロードのサイズに関係なく、ラップするストリームの`highWaterMark`(約16 KiB)で制限されます。ラッパーはバイトが流れる際に観察しますが、決してバッファリングしません。時間はHTTPサーバの`requestTimeout`(デフォルト30秒、`server.requestTimeout`で設定可能)で制限されます。 + + +## マルチパートのパート単位上限 + + +`server.maxBodyBytes`はワイヤ上の合計バイト数を制限しますが、合理的なボディ上限内に収まる攻撃には対応できません: + +- **高カーディナリティ**:100万個の小さなフィールド × 32バイト = 約32 MiBにすぎませんが、プラットフォームのマルチパートパーサーは100万個の`FormData`エントリとフィールドごとの文字列を割り当てます。 +- **長いフィールド名**:1個のフィールドに1 MiBの名前があると、ワイヤ上のバイト数は小さいですが、パーサーは1 MiBの文字列を割り当てます。 +- **ファイル偽装フィールド**:`filename=`なしの大きなブロブはパーサーで文字列フィールドとして扱われ、下流の`file()`サイズポリシーをバイパスします。 + +`server.multipart.*`を使うと、ストリーミングパース中にパート単位の形状を制限できます。任意のサブ制限を正の値に設定すると、マルチパートリクエストはプラットフォームの`Request.formData()`ではなく`busboy`でパースされ、設定された制限がバイトの流れる中で適用されます。いずれかの制限を超えると、問題のあるパートが完全にバッファリングされる *前* にHTTP 413で拒否されます。 + +```mjs filename="react-server.config.mjs" +export default { + server: { + multipart: { + maxFileSize: 10 * 1024 * 1024, // ファイルあたり10 MiB + maxFieldSize: 1 * 1024 * 1024, // テキストフィールドあたり1 MiB + maxFiles: 10, // リクエストあたり最大10ファイル + maxFields: 100, // リクエストあたり最大100テキストフィールド + maxParts: 200, // 合計200パート(ファイル+フィールド) + maxFieldNameSize: 200, // フィールド名あたり200バイト + }, + }, +}; +``` + +| 制限 | 防御対象 | +|---|---| +| `maxFileSize` | 寛容なボディ上限内でも、サイズ超過のファイルアップロード | +| `maxFieldSize` | ファイル偽装フィールド、サイズ超過のテキスト値 | +| `maxFiles` | 多数の`File`ラッパーを割り当てる多ファイル送信 | +| `maxFields` | 高カーディナリティのフィールド攻撃 | +| `maxParts` | エントリ総数の上限(ファイル+フィールド合計) | +| `maxFieldNameSize` | 長いフィールド名による文字列割り当て攻撃 | + +すべてのサブ制限はデフォルトで`0`(無効)です。*すべての*サブ制限が無効の場合、busboyは呼び出されず、マルチパートボディはプラットフォームパーサーにそのまま渡されます。オーバーヘッドはゼロです。 + +パースされた`FormData`はプラットフォームパーサーが生成するものと機能的に同等です(filename、MIMEタイプ、サイズ、バイトが保持されます)。パートごとの`Content-Transfer-Encoding`のみ異なりますが、HTML5仕様は`multipart/form-data`に対してこれを廃止しており、モダンなブラウザは送信しないため、実用上の影響はありません。統合テストスイートのA/B同等性テストがこの特性を保証します。 + +この上限はNodeの`createMiddleware`パスにのみ適用されます。エッジ/サーバレスアダプタ(Cloudflare Workers、Vercel Functionsなど)はプラットフォームレベルでマルチパート制限を持っており、この設定の影響を受けません。 + + +## CSRF / Originバリデーション + + +`server.csrf`はリクエストの`Origin`(または`Referer`)ヘッダを信頼済みオリジン集合と照合することで、サーバ関数のアクションPOSTをクロスサイトリクエストフォージェリ(CSRF)から防御します。 + +脅威の範囲は最初に思えるよりも限定的です。JS駆動のアクション呼び出し(`react-server-action`カスタムヘッダ付きの`fetch()`)はすでに安全です。任意のカスタムヘッダはリクエストをCORS非単純化するため、ブラウザはプリフライトを送信し、ランタイムは要求外のクロスオリジンプリフライトを拒否します。明示的な防御が必要なのは**フォーム送信アクションPOST**です:`multipart/form-data`ボディと`$ACTION_ID_`フィールドを持つ``。この形式はCORS単純であり、ブラウザはプリフライトなしで送信するため、受信側のアプリがソースを検証しない限り、悪意のあるサイトがクロスオリジンでフォーム送信できてしまいます。 + +```mjs filename="react-server.config.mjs" +export default { + server: { + csrf: { + mode: "lax", // デフォルト + allowedOrigins: [ + "https://host.example.com", + /^https:\/\/[^.]+\.partner\.com$/, + ], + }, + }, +}; +``` + +| モード | Origin / Refererなし | Originあり & 信頼済み | Originあり & 信頼外 | +|---|---|---|---| +| `"lax"`(デフォルト) | 許可 | 許可 | **403** | +| `"strict"` | **403** | 許可 | **403** | +| `false` / `"off"` | 許可 | 許可 | 許可 | + +**信頼済みオリジン集合**は既存の設定から暗黙的に構築されます: + +1. リクエスト自身の解決済みオリジン(プロキシ対応)— 同一オリジンのフォーム送信は設定なしで常に動作 +2. `server.origin` — 正規の設定済みアイデンティティ +3. `server.cors.origin` / `origins`(明示的な値で設定されている場合のみ、`*` / `true`は除く)— CORS信頼済みパートナーは通常CSRF信頼済みでもあります +4. `server.csrf.allowedOrigins` — CSRF信頼がCORS信頼と異なるケースのための明示的な追加 + +**リモートコンポーネント:明示的な設定が必要なケース。** ホストアプリがこのアプリのリモートコンポーネントを埋め込む場合、ユーザのブラウザにはリモート(このアプリ)をターゲットとするフォームが表示されます。送信時、ブラウザは`Origin: `でリモートにクロスオリジンPOSTします。`server.csrf.allowedOrigins`にエントリがなければ、リモートは正当なフォーム送信を403で拒否します。これは意図的な設計です — リモートオペレータは、どのホストオリジンがアクションエンドポイントを呼び出せるかを明示的に宣言しなければなりません。 + +```mjs filename="remote-app/react-server.runtime.config.mjs" +export default { + server: { + cors: true, + csrf: { + allowedOrigins: [ + "https://host.example.com", + "https://staging-host.example.com", + ], + }, + }, +}; +``` + +**拒否レスポンス:** ヘッダ`x-react-server-action-error: csrf_origin_mismatch`(またはOriginなしのstrictモードでは`csrf_origin_missing`)付きのHTTP `403 Forbidden`。ハンドラは実行されず、ボディはパースされません。 + +**この機能の対象外:** トークンベースCSRF(double-submit cookie / セッション単位のnonce)。これは高価値アクションに適した、より厳格な防御ですが、ランタイムが代わりに合成できないセッション認識が必要です。これを必要とするアプリは、アクションディスパッチの前段ミドルウェアとして実装できます。 + ## Keep-Aliveとタイムアウト diff --git a/docs/src/pages/ja/(pages)/features/server-function-limits.mdx b/docs/src/pages/ja/(pages)/features/server-function-limits.mdx index e8dcc4c1..ec9ec870 100644 --- a/docs/src/pages/ja/(pages)/features/server-function-limits.mdx +++ b/docs/src/pages/ja/(pages)/features/server-function-limits.mdx @@ -39,6 +39,8 @@ import Link from "../../../../components/Link.jsx"; | `maxStringLength` | `16 MiB` | デコード前の単一文字列行の長さ | | `maxStreamChunks` | `10000` | デコードされた`ReadableStream`、`AsyncIterable`、`Iterator`のチャンク数 | +これらの上限はデコード *中* に実行されます。生のリクエストボディに対するワイヤレベルの上限は HTTP サーバ設定側で別途強制されます。デコーダレベルのチェックが実行される前にメモリ DoS を防ぐパース前の上限については、[`server.maxBodyBytes`](/features/http-layer#body-size-cap) を参照してください。 + 各上限は独立しています。1つを上書きしても、他の上限がデフォルトにリセットされることはありません。 diff --git a/examples/remote/react-server.runtime.config.mjs b/examples/remote/react-server.runtime.config.mjs index 807613e4..749d45ba 100644 --- a/examples/remote/react-server.runtime.config.mjs +++ b/examples/remote/react-server.runtime.config.mjs @@ -1,6 +1,41 @@ +// Runtime config for the remote example. +// +// This app is invoked in two distinct ways: +// +// 1. Server-to-server: the host fetches rendered components from +// this remote app via `@lazarv/react-server/remote`. No browser +// is involved, so CORS / CSRF do not apply. +// 2. Browser form submits: components rendered into the host's +// HTML can include `` elements whose action targets a +// server function on THIS remote app. When the user submits, +// the browser POSTs cross-origin (Origin = host) to this +// remote (different origin). Without explicit trust, the +// CSRF check rejects the submit with HTTP 403. +// +// `server.csrf.allowedOrigins` declares which host origins may +// invoke our action endpoints via form submit. The companion +// `server.cors` config controls cross-origin XHR/fetch — the same +// host needs to be in both lists for a complete integration. +// +// Adjust the origin list to match your real host deployments; +// these defaults assume the local-dev `pnpm dev` setup where the +// host runs on :3000 and this remote runs on :3001. export default { - cors: true, resolve: { shared: ["DataProvider"], }, + server: { + cors: true, + csrf: { + mode: "lax", + allowedOrigins: [ + // Local-dev host app + "http://localhost:3000", + "http://127.0.0.1:3000", + // Add any production host origins that embed components from + // this remote, e.g.: + // "https://app.example.com", + ], + }, + }, }; diff --git a/packages/react-server/config/schema.d.ts b/packages/react-server/config/schema.d.ts index 96e8e42d..a85c3913 100644 --- a/packages/react-server/config/schema.d.ts +++ b/packages/react-server/config/schema.d.ts @@ -232,6 +232,185 @@ export interface ServerConfig { */ maxConcurrentRequests?: number; + /** + * Pre-parse cap on the raw request body in bytes. When set to a + * positive value, oversized payloads are rejected *before* the + * WHATWG `Request` is constructed: + * + * 1. If the client honestly declared `Content-Length` over the + * cap, the server responds 413 immediately and reads zero + * body bytes. + * 2. Otherwise the underlying Node stream is observed with a + * running counter; on overflow the source socket is destroyed + * to bound resource usage. Honest-Content-Length traffic gets + * a clean 413; chunked / mis-declared traffic surfaces as a + * socket-level error on the client side — the trade-off for + * not reading the rest of an attacker-controlled payload to + * deliver a courtesy status code. + * + * Per-decode limits in `serverFunctions.limits.*` still gate + * post-parse shape inside the Server Function decoder regardless + * of this setting. + * + * **Default: `0` (disabled).** No cap is applied unless you set + * one explicitly. This keeps the dev / start fast path identical + * to the unwrapped `req` stream behaviour and avoids surprising + * users whose deployment already enforces a body limit at an + * upstream proxy / platform edge. Pick a value (e.g. + * `32 * 1024 * 1024` for 32 MiB) when you want the runtime itself + * to apply the cap — typically when running without a reverse + * proxy in front, or when defence-in-depth against direct hits + * matters. + * + * @default 0 (disabled) + * @example `maxBodyBytes: 32 * 1024 * 1024` + */ + maxBodyBytes?: number; + + /** + * CSRF / Origin validation for form-submit action POSTs. + * + * **What's protected:** `` submissions with + * `multipart/form-data` bodies that carry a `$ACTION_ID_` + * field. These requests are CORS-simple — the browser does not + * preflight them — so a malicious site can submit them cross- + * origin unless we verify the `Origin` / `Referer` header against + * a trusted set. + * + * **What's NOT protected (because it's already safe):** JS-driven + * action calls via `fetch()` with the custom `react-server-action` + * header. Adding any custom header makes a request not CORS- + * simple, forcing the browser to preflight; the runtime refuses + * cross-origin preflights unless CORS is explicitly enabled for + * the path. + * + * **Trusted-origin set, in implicit priority:** + * + * 1. The request's own resolved origin (trustProxy-aware), so + * same-origin form posts work without any config. + * 2. `server.origin` — the canonical configured identity, useful + * when the app is reachable at multiple URLs. + * 3. `server.cors.origin` / `origins` when configured with + * explicit values (not `*` / `true`) — apps that have CORS + * allow-lists usually want the same set to be CSRF-trusted. + * 4. `server.csrf.allowedOrigins` — explicit additions for + * cases where CSRF trust differs from CORS trust (typically + * remote-component hosts that embed forms targeting this + * app's actions). + * + * **Remote components:** when a host app embeds remote components + * from this app, the user's browser submits embedded form POSTs + * cross-origin to this remote. The host's origin must be in this + * app's `csrf.allowedOrigins` (or in CORS) — otherwise legitimate + * embedded form submits get rejected with HTTP 403. + * + * Set to `false` to disable validation entirely (e.g., when an + * upstream policy already handles CSRF). + * + * @example + * ```ts + * csrf: { + * mode: "lax", // default + * allowedOrigins: ["https://host.example.com", /\.partner\.com$/], + * } + * ``` + */ + csrf?: + | false + | { + /** + * Validation mode. + * + * - `"lax"` (default): allow when no `Origin`/`Referer` is + * present (server-to-server, curl, native apps); require + * trust when Origin is present. + * - `"strict"`: require Origin to be present and trusted. + * Reject requests with missing Origin. + * - `false` / `"off"`: disable validation. + * + * @default "lax" + */ + mode?: "lax" | "strict" | "off" | false; + /** + * Additional trusted origins. May be string literals + * (`"https://example.com"`) or `RegExp` patterns + * (`/^https:\/\/[^.]+\.example\.com$/`). The request's own + * origin, `server.origin`, and explicit CORS origins are + * always implicitly trusted; this field adds to that set. + */ + allowedOrigins?: (string | RegExp)[]; + }; + + /** + * Per-part caps applied during streaming multipart/form-data + * parsing. When any sub-limit is set to a positive value, + * multipart requests are parsed via busboy with the configured + * limits enforced as bytes flow — overflow on any limit rejects + * with HTTP 413 *before* the offending part is fully buffered. + * + * Defends against attacks `maxBodyBytes` cannot bound: + * + * - High-cardinality: 1M small fields fit inside any reasonable + * `maxBodyBytes`, but the parser still allocates 1M FormData + * entries. `maxFields` / `maxParts` cap the entry count. + * - Long field names: a single field with a 1 MiB name has + * small wire bytes but allocates a 1 MiB string. + * `maxFieldNameSize` caps it. + * - File-as-field smuggling: a large blob without `filename=` + * bypasses any downstream `file()` size policy. + * `maxFieldSize` catches this regardless of the part's + * declared role. + * + * All sub-limits default to `0` (disabled). When *every* + * sub-limit is disabled, busboy is never invoked and the request + * body passes through to `Request.formData()` unchanged — zero + * overhead. + * + * @example + * ```js + * multipart: { + * maxFileSize: 10 * 1024 * 1024, // 10 MiB per file + * maxFiles: 5, + * maxFields: 100, + * maxFieldNameSize: 200, + * } + * ``` + */ + multipart?: { + /** + * Maximum bytes per file part. Bytes past the limit are not + * buffered — busboy emits the limit signal as soon as the + * counter exceeds the cap. + * @default 0 (disabled) + */ + maxFileSize?: number; + /** + * Maximum bytes per non-file (text) field value. + * @default 0 (disabled) + */ + maxFieldSize?: number; + /** + * Maximum number of file parts in a single request. + * @default 0 (disabled) + */ + maxFiles?: number; + /** + * Maximum number of non-file (text) fields in a single request. + * @default 0 (disabled) + */ + maxFields?: number; + /** + * Maximum total parts (files + fields). + * @default 0 (disabled) + */ + maxParts?: number; + /** + * Maximum length of a field name in bytes. + * @default 0 (disabled) + */ + maxFieldNameSize?: number; + }; + /** * Graceful shutdown timeout in milliseconds. After receiving SIGTERM/SIGINT, * the server stops accepting new connections and waits up to this duration diff --git a/packages/react-server/config/schema.mjs b/packages/react-server/config/schema.mjs index f9dc9adc..238e963d 100644 --- a/packages/react-server/config/schema.mjs +++ b/packages/react-server/config/schema.mjs @@ -105,6 +105,28 @@ export const DESCRIPTIONS = { "Request timeout in milliseconds. Maximum time allowed for the client to send the complete request. Set to 0 to disable. Default: 30000.", "server.maxConcurrentRequests": "Maximum concurrent requests before the server responds with 503. Set to 0 to disable. Default: 0 (disabled).", + "server.maxBodyBytes": + "Pre-parse cap on the raw request body in bytes. When set to a positive value, oversized payloads are rejected before the WHATWG Request is constructed: declared `Content-Length` over the cap → 413 with no body read; chunked / mis-declared bodies surface as a socket-level error mid-stream (the cap does not read the rest of an attacker-controlled payload to deliver a courtesy status). Applies to every body-bearing POST/PUT/PATCH/DELETE. Per-decode limits in `serverFunctions.limits.*` still gate post-parse shape. Default: 0 (disabled) — pick a value (e.g. `32 * 1024 * 1024`) when you want the runtime to apply the cap directly, typically without a reverse proxy in front.", + "server.csrf": + 'CSRF / Origin validation for form-submit action POSTs (`` with multipart/form-data). JS-driven action calls are not affected — the `react-server-action` custom header already forces a CORS preflight that the runtime refuses unless CORS is explicitly enabled. Set to `false` to disable validation entirely. Object form configures `mode` and `allowedOrigins`.', + "server.csrf.mode": + 'CSRF validation mode. `"lax"` (default): allow when no `Origin`/`Referer` header is present (server-to-server, curl, native apps), require trust when Origin is present. `"strict"`: require Origin to be present and trusted. `false` / `"off"`: disable.', + "server.csrf.allowedOrigins": + "Additional origins (string or RegExp) trusted for cross-origin form-submit action POSTs. The trusted set always implicitly includes the request's own resolved origin and `server.origin`, plus any explicit entries in `server.cors.origin/origins`. Use this field to declare host origins that may embed remote components or otherwise submit forms to this app.", + "server.multipart": + "Per-part caps applied during streaming multipart parsing. When any sub-limit is set to a positive value, multipart/form-data requests are parsed with busboy (instead of the platform `Request.formData()`) so per-part overflow rejects with 413 BEFORE the offending part is fully buffered. Defends against high-cardinality (1M small fields), long-field-name, and file-as-field smuggling attacks that `maxBodyBytes` cannot bound. All sub-limits default to 0 (disabled).", + "server.multipart.maxFileSize": + "Maximum bytes per file part. Bytes past the limit are not buffered. Default: 0 (disabled).", + "server.multipart.maxFieldSize": + "Maximum bytes per non-file (text) field value. Default: 0 (disabled).", + "server.multipart.maxFiles": + "Maximum number of file parts in a single request. Default: 0 (disabled).", + "server.multipart.maxFields": + "Maximum number of non-file (text) fields in a single request. Default: 0 (disabled).", + "server.multipart.maxParts": + "Maximum total parts (files + fields). Default: 0 (disabled).", + "server.multipart.maxFieldNameSize": + "Maximum length of a field name in bytes. Default: 0 (disabled).", "server.shutdownTimeout": "Graceful shutdown timeout in milliseconds. Time to wait for in-flight requests to drain after SIGTERM/SIGINT. Default: 25000.", "server.connectionsCheckingInterval": @@ -616,6 +638,73 @@ export function generateJsonSchema() { { type: "integer", minimum: 0 }, "server.maxConcurrentRequests" ), + maxBodyBytes: prop( + { type: "integer", minimum: 0 }, + "server.maxBodyBytes" + ), + csrf: prop( + { + oneOf: [ + { type: "boolean", enum: [false] }, + { + type: "object", + properties: { + mode: prop( + { + oneOf: [ + { type: "string", enum: ["lax", "strict", "off"] }, + { type: "boolean", enum: [false] }, + ], + }, + "server.csrf.mode" + ), + allowedOrigins: prop( + { + type: "array", + items: { type: "string" }, + }, + "server.csrf.allowedOrigins" + ), + }, + additionalProperties: false, + }, + ], + }, + "server.csrf" + ), + multipart: prop( + { + type: "object", + properties: { + maxFileSize: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxFileSize" + ), + maxFieldSize: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxFieldSize" + ), + maxFiles: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxFiles" + ), + maxFields: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxFields" + ), + maxParts: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxParts" + ), + maxFieldNameSize: prop( + { type: "integer", minimum: 0 }, + "server.multipart.maxFieldNameSize" + ), + }, + additionalProperties: false, + }, + "server.multipart" + ), shutdownTimeout: prop( { type: "integer", minimum: 0 }, "server.shutdownTimeout" diff --git a/packages/react-server/config/validate.mjs b/packages/react-server/config/validate.mjs index d3caee26..fe684b66 100644 --- a/packages/react-server/config/validate.mjs +++ b/packages/react-server/config/validate.mjs @@ -296,6 +296,50 @@ const REACT_SERVER_SCHEMA = { headersTimeout: optional(is.number), requestTimeout: optional(is.number), maxConcurrentRequests: optional(is.number), + maxBodyBytes: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + csrf: optional( + falseOrShape({ + mode: optional( + custom( + (v) => + v === "lax" || v === "strict" || v === false || v === "off", + 'one of "lax" | "strict" | "off" | false' + ) + ), + allowedOrigins: optional( + arrayOf( + custom( + (v) => typeof v === "string" || v instanceof RegExp, + "string or RegExp" + ) + ) + ), + }) + ), + multipart: optional( + objectShape({ + maxFileSize: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + maxFieldSize: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + maxFiles: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + maxFields: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + maxParts: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + maxFieldNameSize: optional( + custom((v) => Number.isInteger(v) && v >= 0, "non-negative integer") + ), + }) + ), shutdownTimeout: optional(is.number), connectionsCheckingInterval: optional(is.number), clusterRespawnLimit: optional(is.number), @@ -675,6 +719,17 @@ const EXAMPLES = { "server.headersTimeout": `server: { headersTimeout: 66000 }`, "server.requestTimeout": `server: { requestTimeout: 30000 }`, "server.maxConcurrentRequests": `server: { maxConcurrentRequests: 100 }`, + "server.maxBodyBytes": `server: { maxBodyBytes: 32 * 1024 * 1024 }`, + "server.csrf": `server: { csrf: { mode: "lax", allowedOrigins: ["https://host.example.com"] } } // or { csrf: false } to disable`, + "server.csrf.mode": `server: { csrf: { mode: "lax" } } // "lax" | "strict" | false`, + "server.csrf.allowedOrigins": `server: { csrf: { allowedOrigins: ["https://host.example.com", /\\.partner\\.com$/] } }`, + "server.multipart": `server: { multipart: { maxFileSize: 10 * 1024 * 1024, maxFiles: 5, maxFields: 100 } }`, + "server.multipart.maxFileSize": `server: { multipart: { maxFileSize: 10 * 1024 * 1024 } }`, + "server.multipart.maxFieldSize": `server: { multipart: { maxFieldSize: 1 * 1024 * 1024 } }`, + "server.multipart.maxFiles": `server: { multipart: { maxFiles: 10 } }`, + "server.multipart.maxFields": `server: { multipart: { maxFields: 100 } }`, + "server.multipart.maxParts": `server: { multipart: { maxParts: 100 } }`, + "server.multipart.maxFieldNameSize": `server: { multipart: { maxFieldNameSize: 200 } }`, "server.shutdownTimeout": `server: { shutdownTimeout: 25000 }`, "server.connectionsCheckingInterval": `server: { connectionsCheckingInterval: 5000 }`, "server.clusterRespawnLimit": `server: { clusterRespawnLimit: 20 }`, diff --git a/packages/react-server/lib/dev/create-server.mjs b/packages/react-server/lib/dev/create-server.mjs index de310604..f540e5a7 100644 --- a/packages/react-server/lib/dev/create-server.mjs +++ b/packages/react-server/lib/dev/create-server.mjs @@ -1205,6 +1205,8 @@ export default async function createServer(root, options) { options.port ?? sys.getEnv("PORT") ?? config.server?.port ?? 3000 }`, trustProxy: config.server?.trustProxy ?? options.trustProxy, + maxBodyBytes: config.server?.maxBodyBytes ?? 0, + multipart: config.server?.multipart, }) ); diff --git a/packages/react-server/lib/http/middleware.mjs b/packages/react-server/lib/http/middleware.mjs index 3ab87f64..c59a5a13 100644 --- a/packages/react-server/lib/http/middleware.mjs +++ b/packages/react-server/lib/http/middleware.mjs @@ -1,9 +1,40 @@ -import { Readable } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { parse as __cookieParse, serialize as __cookieSerialize } from "cookie"; import { isDeno } from "../sys.mjs"; import { compose } from "./middlewares/compose.mjs"; + +// NOTE: multipart-cap.mjs (and busboy via that file) is *dynamically* +// imported below — see the multipart branch in the body-prep block. +// Static imports here would chain into the edge / serverless adapter +// bundles via http/index.mjs's re-export of createMiddleware, pulling +// in `busboy` + `node:stream` deps that workerd and other edge +// runtimes can't resolve. The cheap shape checks (hasMultipartLimits, +// isMultipartContentType) are inlined below for the same reason — we +// avoid touching multipart-cap.mjs unless a request actually qualifies. +const MULTIPART_LIMIT_KEYS = [ + "maxFileSize", + "maxFieldSize", + "maxFiles", + "maxFields", + "maxParts", + "maxFieldNameSize", +]; + +function hasMultipartLimits(limits) { + if (!limits || typeof limits !== "object") return false; + for (const k of MULTIPART_LIMIT_KEYS) { + const v = limits[k]; + if (typeof v === "number" && v > 0) return true; + } + return false; +} + +function isMultipartContentType(contentType) { + if (typeof contentType !== "string") return false; + return /^\s*multipart\/form-data\b/i.test(contentType); +} import { ContextStorage } from "../../server/context.mjs"; import { getRuntime } from "../../server/runtime.mjs"; import { @@ -51,8 +82,137 @@ export function normalizeHandler(handler) { return Array.isArray(handler) ? compose(handler) : handler; } +/** + * Marker error emitted by the body-cap Transform when the running byte + * counter exceeds the configured ceiling. Carries the observed and + * limit values for logging; the middleware's outer catch checks + * `err.code === "BODY_TOO_LARGE"` to map to a 413 response. + */ +class BodySizeError extends Error { + constructor(observed, limit) { + super(`request body exceeded maxBodyBytes (${observed} > ${limit})`); + this.code = "BODY_TOO_LARGE"; + this.observed = observed; + this.limit = limit; + } +} + +/** + * Symbol stamped on the source `IncomingMessage` when the body-cap + * Transform observes overflow. The middleware checks this flag both + * in its outer catch (when the error escapes the handler) AND after + * `run(ctx)` returns successfully (when the framework catches the + * read error internally and produces a 5xx render). Using a flag + * means the cap's 413 wins regardless of how the handler / framework + * routes a body-read error. + */ +const BODY_CAP_OVERFLOW = Symbol("react-server.bodyCapOverflow"); + +/** + * Wrap a Node `IncomingMessage` (or any Readable) with a counting + * Transform that errors on overflow. The wrapper is what we hand to + * the WHATWG `Request` constructor as the body, so bytes flow lazily + * through it as the consumer (e.g. `request.formData()` / + * `request.text()` / a streaming handler) reads. Memory peak is + * O(consumer's chunk window), not O(body size). + * + * On overflow: + * - The underlying socket is destroyed (releases the connection). + * - The Transform emits a `BodySizeError`, which surfaces to whoever + * is reading the body — the middleware's outer catch maps it to + * 413 when no response has been sent yet. + * + * Note: we explicitly do not enable `autoDestroy: false` — the + * Transform must propagate destroy to the source so a partial-read + * consumer (handler that only reads some of the body, then returns) + * still releases the socket cleanly. + */ +function wrapWithBodyCap(source, maxBytes) { + let total = 0; + const transform = new Transform({ + transform(chunk, _enc, cb) { + total += chunk.length; + if (total > maxBytes) { + // Stamp the overflow flag and destroy the source. Two + // observations led us here after several attempts at a + // gentler shutdown: + // + // 1. The cheap declared-`Content-Length` rejection (in the + // middleware, before this Transform is even created) + // delivers a clean 413 to clients with honest length + // headers. That's the path real users hit. + // + // 2. The streaming-overflow path is by definition either + // a chunked-transfer client or one lying about its + // Content-Length — which in production means hostile + // or buggy traffic. Trying to deliver a courtesy 413 + // to that traffic requires draining the rest of the + // attacker-controlled payload (so Node will flush the + // response with FIN instead of RST), which is exactly + // the work the cap is supposed to *avoid*. + // + // So we accept that streaming-overflow surfaces as a socket + // close on the client side. The defense is intact: the + // server allocated bounded memory (Transform highWaterMark + // ~16 KiB), did not process the payload, and dropped the + // connection. Honest large-upload clients hit the cheap + // declared-length path and get a clean 413; hostile + // chunked-uploads see RST. + source[BODY_CAP_OVERFLOW] = { observed: total, limit: maxBytes }; + try { + source.destroy(); + } catch { + // ignore — overflow path takes precedence + } + cb(new BodySizeError(total, maxBytes)); + return; + } + cb(null, chunk); + }, + }); + // Pipe the source into the transform so backpressure flows + // correctly. If the source errors (client abort, socket reset), + // forward the error to the transform so the consumer sees it + // instead of a silent truncation. + source.on("error", (err) => transform.destroy(err)); + source.pipe(transform); + return transform; +} + export function createMiddleware(handler, options = {}) { - const { origin, trustProxy = false, defaultNotFound = false } = options; + const { + origin, + trustProxy = false, + defaultNotFound = false, + // Wire-level body cap. Enforced *before* the WHATWG Request is + // constructed so an oversized payload never reaches any handler: + // + // 1. If the client honestly declared `Content-Length` over the + // cap, respond 413 immediately and read zero body bytes. + // 2. Otherwise drain the underlying Node stream with a running + // counter; on overflow destroy the stream (frees the socket) + // and respond 413. + // + // The cap is an HTTP-server policy, not a renderer / Server + // Function policy — it applies uniformly to every body-bearing + // POST/PUT/PATCH/DELETE regardless of route, content-type, or + // whether a Server Function dispatch will run downstream. Per-arg + // / per-decode limits (`serverFunctions.limits.*`) still gate + // post-parse shape inside the decoder. + // + // `0` / falsy disables the cap (e.g. behind a trusted proxy that + // already enforces a body limit). `Number.POSITIVE_INFINITY` is + // permitted but pointless — prefer `0` to express "off". + maxBodyBytes = 0, + // Per-part multipart caps applied via streaming busboy parse. + // Defends against high-cardinality / long-name / file-as-field + // attacks that `maxBodyBytes` cannot bound (see + // multipart-cap.mjs's docstring for the gap analysis). All + // sub-limits default to disabled; busboy is only invoked when + // at least one limit is set AND the request is + // multipart/form-data. + multipart = null, + } = options; const run = normalizeHandler(handler); return async function nodeAdapter(req, res, next) { let ctx; @@ -96,24 +256,166 @@ export function createMiddleware(handler, options = {}) { headers: fetchHeaders, }; if (!(req.method === "GET" || req.method === "HEAD")) { - if (isDeno) { - // Under Deno's Node compat, passing the raw stream as body can cause - // BadResource errors when the body is consumed later (e.g. formData()). - // Buffer the body so the Request owns the data. + // ── Body-presence gate (RFC 7230 §3.3.3 rule 5) ── + // A request has a body iff Content-Length > 0 OR + // Transfer-Encoding includes "chunked". When neither holds, + // there's no body to wrap / drain — skip the cap machinery + // entirely so the empty-body POST/PUT/PATCH/DELETE case + // (e.g. logout endpoints, 204-shaped mutations) costs zero. + const contentLengthHeader = headersObj["content-length"]; + const transferEncodingHeader = headersObj["transfer-encoding"]; + const declaredLen = + contentLengthHeader != null ? Number(contentLengthHeader) : NaN; + const isChunked = + typeof transferEncodingHeader === "string" && + /\bchunked\b/i.test(transferEncodingHeader); + const hasBody = + (Number.isFinite(declaredLen) && declaredLen > 0) || isChunked; + + // ── Pre-parse body cap (HTTP-server policy) ── + // Layer 1: cheap declared-length check. Honest clients send + // `Content-Length` on any non-chunked POST. Catches the + // obvious "uploading 5 GB" case without reading any wire + // bytes — and without forcing the lazy wrapper to do work + // for a request we can already reject from the headers. + if ( + maxBodyBytes > 0 && + Number.isFinite(declaredLen) && + declaredLen > maxBodyBytes + ) { + res.statusCode = 413; + res.setHeader("connection", "close"); + return res.end(); + } + + if (!hasBody) { + // No body to wrap. Leave `requestInit.body` unset — the + // WHATWG Request constructor treats that as a body-less + // request, which is exactly what RFC 7230 says this is. + } else if ( + hasMultipartLimits(multipart) && + isMultipartContentType(headersObj["content-type"]) + ) { + // Multipart with per-part caps. Parse via busboy with + // streaming limits BEFORE constructing the WHATWG Request. + // On overflow we get a clean 413 pre-handler; on success + // the parsed FormData is handed to the Request constructor + // (which will re-serialize with a fresh boundary, so we + // drop the original content-type header). + // + // Why this is a separate branch from the body-cap wrap: + // busboy directly consumes the `req` Readable, so we + // can't ALSO wrap it with the byte-counter Transform. + // The body-cap's cheap declared-length check above still + // ran (`Content-Length > maxBodyBytes` returns 413 + // pre-busboy), which covers the "huge total payload" + // case for honest clients. For chunked / mis-declared + // multipart bodies, busboy's `maxFileSize` / + // `maxFieldSize` per-part limits are the bound. + // + // The dynamic import here is load-bearing: it keeps the + // multipart-cap module (and its `busboy` + `node:stream` / + // `Buffer` deps) out of edge / serverless adapter bundles + // that re-export `createMiddleware` from http/index.mjs but + // never actually invoke it at runtime. Static imports + // would force the bundler to resolve busboy for workerd + // and friends, where it can't run. + const { drainRemaining, MultipartCapError, parseMultipartWithCap } = + await import("./multipart-cap.mjs"); + try { + const formData = await parseMultipartWithCap(req, multipart); + requestInit.body = formData; + // Strip content-type and content-length so the WHATWG + // Request constructor sets a fresh multipart boundary + // matching the re-serialized body. Leaving the original + // header would give the Request a body that doesn't + // match its declared boundary. + delete requestInit.headers["content-type"]; + delete requestInit.headers["content-length"]; + } catch (e) { + if (e instanceof MultipartCapError) { + // Drain remaining bytes BEFORE writing 413. Node's + // HTTP server sends RST instead of FIN when the + // request body is unconsumed at response time, which + // surfaces on the client as `UND_ERR_SOCKET / other + // side closed` and swallows our status code. After + // draining (memory bounded by HWM ~16 KiB, time + // bounded by `server.requestTimeout`), the response + // flushes cleanly with FIN. + await drainRemaining(req); + if (!res.headersSent) { + res.statusCode = 413; + res.setHeader("connection", "close"); + return res.end(); + } + return; + } + // Malformed multipart (bad boundary, truncated, etc.) — + // 400 keeps it distinct from "too big" (413) and from + // "server error" (500). + await drainRemaining(req); + if (!res.headersSent) { + res.statusCode = 400; + res.setHeader("connection", "close"); + return res.end(); + } + return; + } + } else if (isDeno) { + // Deno's Node compat doesn't tolerate the raw stream being + // re-consumed via Request.formData() (BadResource), so we + // buffer here. Note this branch only fires when *no* + // multipart cap is configured — when caps are on, the + // multipart-cap branch above runs (busboy consumes `req` + // once and produces a FormData, which the Request + // constructor serializes from, so there's no double- + // consumption issue for Deno on that path). const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); + let total = 0; + let overflow = false; + try { + for await (const chunk of req) { + total += chunk.length; + if (maxBodyBytes > 0 && total > maxBodyBytes) { + overflow = true; + try { + req.destroy(); + } catch { + // ignore + } + break; + } + chunks.push(chunk); + } + } catch { + if (!overflow) { + res.statusCode = 400; + res.setHeader("connection", "close"); + return res.end(); + } } - requestInit.body = new Uint8Array( - chunks.reduce((acc, c) => acc + c.length, 0) - ); + if (overflow) { + res.statusCode = 413; + res.setHeader("connection", "close"); + return res.end(); + } + const body = new Uint8Array(total); let offset = 0; - for (const chunk of chunks) { - requestInit.body.set(chunk, offset); - offset += chunk.length; + for (const c of chunks) { + body.set(c, offset); + offset += c.length; } + requestInit.body = body; } else { - requestInit.body = req; + // Node fast path: pass the body through lazily. When the + // cap is enabled, wrap with a counting Transform that + // errors on overflow — bytes flow as the consumer reads, + // not all at once at construction time. When the cap is + // disabled (`maxBodyBytes: 0`), pass `req` straight through + // as before — zero overhead for users who terminate body + // limits at an upstream proxy. + requestInit.body = + maxBodyBytes > 0 ? wrapWithBodyCap(req, maxBodyBytes) : req; requestInit.duplex = "half"; // Node streams are half-duplex } } @@ -160,6 +462,16 @@ export function createMiddleware(handler, options = {}) { ctx._otelCtx = otelCtx; let response = await run(ctx); + // Streaming body-cap overflow: the wrapper Transform already + // destroyed the source socket when the cap was breached + // (see wrapWithBodyCap for why). By the time we reach here + // the connection is in some state of teardown — best effort + // is to drop the framework's response on the floor. Honest + // (declared-length) overflows take a separate, earlier path + // that delivers a clean 413 before any handler runs. + if (req[BODY_CAP_OVERFLOW]) { + return; + } if (!response) { if (defaultNotFound && !next) response = new Response("Not Found", { status: 404 }); @@ -289,6 +601,19 @@ export function createMiddleware(handler, options = {}) { // no-op } } + // Body cap tripped while a consumer (Request.formData(), + // Request.text(), or a streaming handler) was reading the + // body. The error may be wrapped by the WHATWG body reader + // depending on runtime — check both `e` and `e.cause`. Map to + // 413 instead of the generic 500 path. If headers are already + // out we can't switch the status; destroy the socket so the + // client at least sees a connection close. + if (isBodySizeError(e)) { + // The wrapper already destroyed the source on overflow — + // see wrapWithBodyCap. Nothing to do here beyond not + // letting the error bubble into the generic 500 path. + return; + } if (e.name !== "AbortError" && e.message !== "aborted") { if (next) next(e); else internalError(res, e); @@ -297,6 +622,19 @@ export function createMiddleware(handler, options = {}) { }; } +function isBodySizeError(e) { + if (!e || typeof e !== "object") return false; + if (e.code === "BODY_TOO_LARGE") return true; + // Walk the `cause` chain — some runtimes wrap the underlying + // stream error when it surfaces through Request.formData()/text(). + let cur = e.cause; + while (cur && typeof cur === "object") { + if (cur.code === "BODY_TOO_LARGE") return true; + cur = cur.cause; + } + return false; +} + function headerFirst(h) { if (Array.isArray(h)) return h[0]; return h; diff --git a/packages/react-server/lib/http/multipart-cap.mjs b/packages/react-server/lib/http/multipart-cap.mjs new file mode 100644 index 00000000..99eb5c48 --- /dev/null +++ b/packages/react-server/lib/http/multipart-cap.mjs @@ -0,0 +1,277 @@ +import Busboy from "busboy"; + +// This module imports `busboy` and uses Node's `Buffer` global. It is +// imported *dynamically* from lib/http/middleware.mjs only when a +// request matches both `hasMultipartLimits(config)` and +// `isMultipartContentType(headers)` — which never happens on edge / +// serverless adapters that don't invoke `createMiddleware`. The +// dynamic import keeps this file out of those adapters' bundles. +// Do not add static `import` re-exports of this module from any +// universal entry point (e.g. http/index.mjs) or that property +// breaks. + +/** + * Streaming multipart parser with per-part caps. + * + * Why this module exists: the platform's `Request.formData()` (undici) + * parses *all* parts and buffers them into memory before resolving. + * `server.maxBodyBytes` bounds total wire bytes, but it does not + * protect against: + * + * - **High-cardinality**: 1M small fields (~1B each) within a + * reasonable body cap still allocates 1M `FormData` entries + * and per-entry strings. + * - **Long field names**: a single field with a 1 MiB name — + * wire is fine, parser allocates a 1 MiB string. + * - **File-as-field smuggling**: a large blob without + * `filename=` is treated as a string field, bypassing + * downstream `file()` size policy. + * + * This parser pipes the request body through busboy, applies the + * configured per-part limits as bytes flow, rejects with + * `MultipartCapError` on overflow (the middleware maps this to a + * pre-`Request` 413), and on success builds a WHATWG `FormData` + * suitable for handing back to `Request` as `requestInit.body`. + * + * Functional equivalence with `Request.formData()` is asserted by an + * A/B integration test (see test/__test__/http-multipart-cap.spec.mjs); + * the only edge-case divergence is `Content-Transfer-Encoding` (which + * the HTML5 spec dropped for `multipart/form-data` and modern + * browsers never emit). + * + * @module + */ + +/** + * Thrown when any per-part limit is exceeded during parsing. The + * middleware checks `instanceof MultipartCapError` and maps to a 413 + * response without invoking any handler. + * + * `limit` is one of: `maxFileSize`, `maxFieldSize`, `maxFiles`, + * `maxFields`, `maxParts`, `maxFieldNameSize`. + */ +export class MultipartCapError extends Error { + /** + * @param {string} limit + * @param {string} [partName] + */ + constructor(limit, partName) { + super( + `multipart limit exceeded: ${limit}` + + (partName ? ` (part: ${partName})` : "") + ); + this.name = "MultipartCapError"; + this.code = "MULTIPART_LIMIT_EXCEEDED"; + this.limit = limit; + this.partName = partName ?? null; + } +} + +/** + * Parse a Node `IncomingMessage`'s body as multipart/form-data with + * per-part caps. Returns a WHATWG `FormData` on success. Throws + * `MultipartCapError` on any limit breach. + * + * IMPORTANT: on rejection the source request is *not* destroyed. + * Destroying the underlying socket here would tear it down before + * the middleware's 413 response can flush, causing clients to see + * `UND_ERR_SOCKET / other side closed` instead of a clean status + * code. The caller (middleware) is responsible for draining the + * remainder via `drainRemaining` and then writing the 413. + * + * @param {import("node:http").IncomingMessage} req + * @param {{ + * maxFileSize?: number, + * maxFieldSize?: number, + * maxFiles?: number, + * maxFields?: number, + * maxParts?: number, + * maxFieldNameSize?: number, + * }} limits + * @returns {Promise} + */ +export function parseMultipartWithCap(req, limits) { + return new Promise((resolve, reject) => { + // Busboy uses `Infinity` as "no limit" for size-based limits and + // `Infinity` for count-based limits as well. We translate `0` / + // missing / non-positive values to `Infinity` (disabled), and + // pass through positive values unchanged. + const bbLimits = { + fileSize: pickLimit(limits.maxFileSize), + fieldSize: pickLimit(limits.maxFieldSize), + files: pickLimit(limits.maxFiles), + fields: pickLimit(limits.maxFields), + parts: pickLimit(limits.maxParts), + // NOTE: busboy's `fieldNameSize` only applies to URL-encoded + // forms (verified by reading busboy@1.6.0 source). For + // multipart, the field name comes directly from the + // `Content-Disposition: form-data; name="..."` parameter + // without any size check. We pass the limit through anyway + // for completeness, but enforce it manually in the field/ + // file event handlers below. + fieldNameSize: pickLimit(limits.maxFieldNameSize), + }; + const maxFieldNameSize = + typeof limits.maxFieldNameSize === "number" && limits.maxFieldNameSize > 0 + ? limits.maxFieldNameSize + : 0; + + let busboy; + try { + busboy = Busboy({ + headers: req.headers, + limits: bbLimits, + // busboy defaults `defParamCharset` to a null decoder, which + // returns raw Latin-1 bytes for `Content-Disposition` + // parameters (filename, name). That mismatches what undici's + // `Request.formData()` does — it decodes those parameters as + // UTF-8 — and surfaces as mojibake on filenames containing + // non-ASCII (e.g. `ファイル-π.dat` becomes `ãã¡ã¤ã«-Ï.dat`). + // Set utf8 to match the platform parser. + defParamCharset: "utf8", + }); + } catch (e) { + reject(e); + return; + } + + const formData = new FormData(); + let settled = false; + const settle = (fn, value) => { + if (settled) return; + settled = true; + // Stop busboy from processing more parts. Do NOT destroy + // `req`: that would RST the socket before the middleware + // writes the 413 response. The middleware drains the + // remainder explicitly after we reject. + try { + req.unpipe(busboy); + } catch { + // ignore — already unpiped + } + fn(value); + }; + const fail = (err) => settle(reject, err); + + // Per-part collectors. Each `file` event hands us a stream; we + // accumulate chunks into a Buffer, then wrap in a File on close. + // busboy emits `limit` on the file stream when fileSize is + // exceeded — that is the pre-buffer signal. + + busboy.on("field", (name, value, info) => { + if (maxFieldNameSize > 0 && name.length > maxFieldNameSize) { + return fail(new MultipartCapError("maxFieldNameSize", name)); + } + if (info?.nameTruncated) { + return fail(new MultipartCapError("maxFieldNameSize", name)); + } + if (info?.valueTruncated) { + return fail(new MultipartCapError("maxFieldSize", name)); + } + formData.append(name, value); + }); + + busboy.on("file", (name, fileStream, info) => { + if (maxFieldNameSize > 0 && name.length > maxFieldNameSize) { + // Drain the file stream so busboy can transition cleanly. + fileStream.resume(); + return fail(new MultipartCapError("maxFieldNameSize", name)); + } + const chunks = []; + let total = 0; + let limitHit = false; + + fileStream.on("data", (chunk) => { + if (limitHit) return; + total += chunk.length; + chunks.push(chunk); + }); + fileStream.on("limit", () => { + limitHit = true; + // Drain the file stream so busboy can move on, but we'll + // already have failed at this point. + fileStream.resume(); + fail(new MultipartCapError("maxFileSize", name)); + }); + fileStream.on("end", () => { + if (limitHit || settled) return; + const buf = Buffer.concat(chunks, total); + // `File` is a global in Node 20+; matches what + // `Request.formData()` produces for file parts. + const file = new File([buf], info?.filename ?? "", { + type: info?.mimeType ?? "application/octet-stream", + }); + formData.append(name, file); + }); + fileStream.on("error", (err) => { + if (settled) return; + fail(err); + }); + }); + + busboy.on("partsLimit", () => fail(new MultipartCapError("maxParts"))); + busboy.on("filesLimit", () => fail(new MultipartCapError("maxFiles"))); + busboy.on("fieldsLimit", () => fail(new MultipartCapError("maxFields"))); + + busboy.on("error", (err) => fail(err)); + busboy.on("close", () => { + if (!settled) settle(resolve, formData); + }); + + // Forward source-side errors so the consumer (handler) sees a + // clean rejection rather than a hanging promise. + req.on("error", (err) => fail(err)); + req.on("aborted", () => + fail(Object.assign(new Error("request aborted"), { code: "ABORTED" })) + ); + + req.pipe(busboy); + }); +} + +/** + * Drain whatever bytes remain on the source request, discarding + * them — used by the middleware after a `MultipartCapError` so + * Node's HTTP server can flush the 413 response cleanly (Node + * sends RST instead of FIN when the response is written before + * the request body is fully consumed). + * + * Memory: chunks are discarded as they arrive (~16 KiB + * highWaterMark peak). Time: bounded by the HTTP server's + * `requestTimeout` (default 30s). + * + * @param {import("node:http").IncomingMessage} req + * @returns {Promise} + */ +export function drainRemaining(req) { + return new Promise((resolve) => { + if (req.readableEnded || req.destroyed || req.complete) { + resolve(); + return; + } + const cleanup = () => { + req.removeListener("data", noop); + req.removeListener("end", cleanup); + req.removeListener("error", cleanup); + req.removeListener("close", cleanup); + req.removeListener("aborted", cleanup); + resolve(); + }; + const noop = () => {}; + req.on("data", noop); + req.on("end", cleanup); + req.on("error", cleanup); + req.on("close", cleanup); + req.on("aborted", cleanup); + req.resume(); + }); +} + +/** + * @param {unknown} v + * @returns {number} + */ +function pickLimit(v) { + if (typeof v === "number" && Number.isFinite(v) && v > 0) return v; + return Infinity; +} diff --git a/packages/react-server/lib/start/create-server.mjs b/packages/react-server/lib/start/create-server.mjs index 0c3e1414..e4bb2075 100644 --- a/packages/react-server/lib/start/create-server.mjs +++ b/packages/react-server/lib/start/create-server.mjs @@ -345,6 +345,8 @@ export default async function createServer(root, options) { options.port ?? sys.getEnv("PORT") ?? config.server?.port ?? 3000 }`, trustProxy: config.server?.trustProxy ?? options.trustProxy, + maxBodyBytes: config.server?.maxBodyBytes ?? 0, + multipart: config.server?.multipart, }); // Node's default `connectionsCheckingInterval` is 30s, meaning slow-headers diff --git a/packages/react-server/package.json b/packages/react-server/package.json index 4d629485..2acb9b9e 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -218,6 +218,7 @@ "algoliasearch": "^5.10.2", "ansi-regex": "^6.0.1", "astring": "^1.9.0", + "busboy": "^1.6.0", "cac": "^6.7.14", "chokidar": "^3.5.3", "cookie": "^1.0.2", diff --git a/packages/react-server/server/action-crypto.mjs b/packages/react-server/server/action-crypto.mjs index 21196f66..50b479b7 100644 --- a/packages/react-server/server/action-crypto.mjs +++ b/packages/react-server/server/action-crypto.mjs @@ -268,19 +268,38 @@ export function encryptActionId(actionId) { } /** - * Try to decrypt a token with a specific key. + * Cheap pre-filters that reject obviously-malformed tokens before they + * reach the AEAD primitive. Bounds match the AES-GCM wire format's + * absolute structural minimum so we never reject a token the cipher + * itself would accept: * - * @param {string} token - base64url-encoded encrypted token - * @param {Buffer} key - 32-byte AES key + * - 12-byte IV + 16-byte auth tag + ≥0-byte ciphertext = 28 bytes min + * (AES-GCM permits empty plaintext, so ciphertext can be 0 bytes) + * - base64url (no padding) of 28 bytes = ⌈28·4/3⌉ = 38 chars min + * - base64url alphabet is `[A-Za-z0-9_-]` + * + * This matters under sustained attacker traffic: every action-shaped + * `POST` runs `decryptActionToken`, and AES-GCM auth-tag verification + * (even when failing) is several orders of magnitude more expensive + * than a charset / length check. Rejecting garbage *before* the + * decode + cipher setup keeps the dispatcher's per-request cost flat + * even when the wire is full of nonsense. + */ +const TOKEN_MIN_LENGTH = 38; +const TOKEN_MIN_DECODED_LENGTH = 28; +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +/** + * Try to decrypt with a specific key, given a pre-decoded ciphertext + * buffer. Decode is hoisted out of this function so it runs once per + * token rather than once per (token, key) pair. + * + * @param {Buffer} data - decoded `iv ‖ tag ‖ ciphertext` bytes + * @param {Buffer} key - 32-byte AES key * @returns {string | null} The decrypted plaintext, or null on failure */ -function tryDecryptWithKey(token, key) { +function tryDecryptWithKey(data, key) { try { - const data = Buffer.from(token, "base64url"); - - // Minimum size: iv(12) + authTag(16) + at least 1 byte ciphertext - if (data.length < 29) return null; - const iv = data.subarray(0, 12); const authTag = data.subarray(12, 28); const ciphertext = data.subarray(28); @@ -373,10 +392,26 @@ function parseTokenPlaintext(plaintext) { export function decryptActionToken(token) { if (!token || typeof token !== "string") return null; + // Cheap pre-filters — see comment above the constants. Garbage tokens + // bail in microseconds without any base64 decode or AES setup. + if (token.length < TOKEN_MIN_LENGTH) return null; + if (!BASE64URL_RE.test(token)) return null; + + // Decode once, share the buffer across every key attempt. Without + // this hoist the decode runs N times for N rotation keys on every + // request — wasted work that grows linearly with the rotation depth. + let data; + try { + data = Buffer.from(token, "base64url"); + } catch { + return null; + } + if (data.length < TOKEN_MIN_DECODED_LENGTH) return null; + // Try primary key, then previous keys for rotation. const keysToTry = [getKey(), ...getPreviousKeys()]; for (const k of keysToTry) { - const plaintext = tryDecryptWithKey(token, k); + const plaintext = tryDecryptWithKey(data, k); if (plaintext !== null) { return parseTokenPlaintext(plaintext); } diff --git a/packages/react-server/server/csrf.mjs b/packages/react-server/server/csrf.mjs new file mode 100644 index 00000000..d20db886 --- /dev/null +++ b/packages/react-server/server/csrf.mjs @@ -0,0 +1,199 @@ +/** + * Cross-Site Request Forgery defence for server-function action POSTs. + * + * The threat: a malicious site can submit a `` + * cross-origin POST that triggers a server function. The browser + * does not preflight `multipart/form-data` requests (CORS-simple), + * so the action handler runs unless something checks the request's + * origin. + * + * JS-driven action calls (with the custom `react-server-action` + * header) ARE preflighted by the browser — adding any custom header + * makes a request not CORS-simple, forcing the browser to preflight, + * which our server refuses unless the operator explicitly enables + * CORS for that path. So this module only defends the form-submit + * shape (multipart/form-data with a `$ACTION_ID_` field). + * + * Trusted-origin set, in priority order: + * + * 1. The request's own resolved origin (so same-origin form posts + * work out of the box without any config). Resolved via the + * runtime's existing trust-proxy logic in `createMiddleware` — + * `context.request.url` is already the canonical, proxy-aware + * origin of the receiving app. + * 2. `config.server.origin` — the canonical configured identity. + * Redundant with (1) when the configured origin matches the + * request, but matters for deployments where the app is + * reachable at multiple URLs (Docker hostname vs. public URL). + * 3. `config.server.cors.origin` / `origins` if configured with + * explicit values (not `*` / `true`). Apps that have explicit + * CORS allow-lists usually want the same set to count as + * CSRF-trusted — the operator has already declared that those + * origins are integration partners. + * 4. `config.server.csrf.allowedOrigins` — explicit additions for + * cases where the operator wants CSRF-trusted origins to differ + * from the CORS set (e.g., remote-component hosts that may not + * need cross-origin fetch but DO submit forms to this app). + * + * Remote components: when a host app embeds remote components, the + * remote operator MUST add the host's origin to + * `server.csrf.allowedOrigins` (or to CORS, via reuse) — otherwise + * legitimate form-submit POSTs from embedded forms get rejected. + * This is by design: the remote operator explicitly declares which + * host origins may invoke their action endpoints. + * + * @module + */ + +/** + * Resolve the request's origin for CSRF validation. Uses the + * `Origin` header first (set by browsers on all cross-origin + * requests and most same-origin POSTs), falls back to parsing the + * origin out of `Referer`. Returns `null` when neither is usable. + * + * Treats `"null"` (opaque origin, sandboxed iframe, file://, etc.) + * as "Origin present and untrusted" — return the literal `"null"` + * so the caller can distinguish absent-vs-opaque. + * + * @param {Request} request + * @returns {string | null} + */ +export function getRequestOrigin(request) { + const origin = request.headers.get("origin"); + if (origin) return origin; + const referer = request.headers.get("referer"); + if (!referer) return null; + try { + return new URL(referer).origin; + } catch { + return null; + } +} + +/** + * Build the set of trusted origins for the current request. + * Returns a `{ literals: Set, patterns: RegExp[] }` pair + * — the literals are O(1) lookups, the patterns are matched + * sequentially for `RegExp` entries in `allowedOrigins`. + * + * @param {Request} request - so the request's own origin is in the + * trusted set (same-origin posts work without config) + * @param {object} config + * @returns {{ literals: Set, patterns: RegExp[] }} + */ +export function resolveTrustedOrigins(request, config) { + const literals = new Set(); + const patterns = []; + + // 1. The request's own origin — same-origin form POSTs always work. + // `context.request.url` was built in createMiddleware using the + // trust-proxy-aware protocol and host, so this is the canonical + // proxy-resolved origin of the receiving app. + try { + literals.add(new URL(request.url).origin); + } catch { + // Malformed URL — leave the literals empty; downstream check fails. + } + + // 2. Explicitly configured origin (canonical identity). + if (typeof config?.server?.origin === "string") { + try { + literals.add(new URL(config.server.origin).origin); + } catch { + // ignore malformed config + } + } + + // 3. CORS allowed origins, when they're explicit (not wildcard). + const corsOrigin = + config?.server?.cors?.origin ?? config?.server?.cors?.origins; + collectCorsOrigins(corsOrigin, literals, patterns); + + // 4. CSRF-specific allow-list. + const csrfAllowed = config?.server?.csrf?.allowedOrigins; + if (Array.isArray(csrfAllowed)) { + for (const entry of csrfAllowed) { + if (typeof entry === "string") { + try { + literals.add(new URL(entry).origin); + } catch { + // ignore malformed entry + } + } else if (entry instanceof RegExp) { + patterns.push(entry); + } + } + } + + return { literals, patterns }; +} + +function collectCorsOrigins(corsOrigin, literals, patterns) { + if (corsOrigin == null || corsOrigin === true || corsOrigin === "*") return; + const list = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; + for (const entry of list) { + if (typeof entry === "string" && entry !== "*") { + try { + literals.add(new URL(entry).origin); + } catch { + // ignore malformed + } + } else if (entry instanceof RegExp) { + patterns.push(entry); + } + } +} + +/** + * Check whether `origin` is in the trusted set. + * + * @param {string | null} origin + * @param {{ literals: Set, patterns: RegExp[] }} trusted + * @returns {boolean} + */ +export function isOriginTrusted(origin, trusted) { + if (typeof origin !== "string" || origin === "" || origin === "null") { + return false; + } + if (trusted.literals.has(origin)) return true; + for (const re of trusted.patterns) { + if (re.test(origin)) return true; + } + return false; +} + +/** + * Decide whether a form-submit action POST passes CSRF validation. + * + * Behaviour by `config.server.csrf.mode`: + * + * - `false` → always allow (CSRF defence disabled) + * - `"lax"` (default) → allow when Origin/Referer is missing + * (server-to-server, curl, native apps); + * require trust when Origin is present + * - `"strict"` → always require trust; reject if Origin + * is missing + * + * @param {Request} request + * @param {object} config + * @returns {{ ok: true } | { ok: false, reason: "csrf_origin_mismatch" | "csrf_origin_missing", origin: string | null }} + */ +export function checkCsrf(request, config) { + const csrfConfig = config?.server?.csrf; + if (csrfConfig === false) return { ok: true }; + + const mode = csrfConfig?.mode ?? "lax"; + if (mode === false || mode === "off") return { ok: true }; + + const origin = getRequestOrigin(request); + if (origin == null) { + if (mode === "strict") { + return { ok: false, reason: "csrf_origin_missing", origin: null }; + } + return { ok: true }; + } + + const trusted = resolveTrustedOrigins(request, config); + if (isOriginTrusted(origin, trusted)) return { ok: true }; + return { ok: false, reason: "csrf_origin_mismatch", origin }; +} diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index d33ca93a..fa80a2e5 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -73,6 +73,7 @@ import { decryptActionToken, wrapServerReferenceMap, } from "./action-crypto.mjs"; +import { checkCsrf } from "./csrf.mjs"; import { requireModule } from "./module-loader.mjs"; import { ScrollRestoration } from "../client/ScrollRestoration.jsx"; @@ -87,6 +88,24 @@ const serverReferenceMap = wrapServerReferenceMap(_serverReferenceMap); // security boundary. const _strictWarnedActions = new Set(); +/** + * Thrown when a form-submit action POST fails CSRF origin + * validation. Caught by the action-dispatch block's catch and + * mapped to HTTP 403 with `x-react-server-action-error` set to + * the specific failure reason. + */ +class CsrfRejectedError extends Error { + constructor(reason, origin) { + super( + `Server function rejected: ${reason}` + + (origin ? ` (origin: ${origin})` : "") + ); + this.name = "CsrfRejectedError"; + this.reason = reason; + this.origin = origin; + } +} + /** * Pre-load the action's source module for a recovered actionId so the * server-function meta registry is populated *before* `decodeReply` @@ -404,6 +423,35 @@ export async function render(Component, props = {}, options = {}) { if (options.middlewareError) { throw options.middlewareError; } + // ── CSRF / Origin validation ── + // + // Only applies to the form-submit shape: multipart/form-data + // with a `$ACTION_ID_` field, no `react-server-action` + // header. Header-based action calls are already CSRF-safe + // because the custom header forces a CORS preflight that we + // never permit cross-origin unless the operator explicitly + // configures CORS for the path. Multipart without the + // header is a CORS-simple request (forms), so a malicious + // site can submit it cross-origin and bypass CORS — Origin + // validation is the defence. + // + // The trusted-origin set is implicit { request's own origin + // (proxy-aware), server.origin, CORS allow-list, csrf + // allowedOrigins }. Same-origin posts pass without config; + // cross-origin posts require explicit allow. See + // server/csrf.mjs for the full resolution. + if ( + isFormData && + (!serverActionHeader || serverActionHeader === "null") + ) { + const csrfResult = checkCsrf(context.request, config); + if (!csrfResult.ok) { + throw new CsrfRejectedError( + csrfResult.reason, + csrfResult.origin + ); + } + } // Pre-resolve the actionId (and any token-recovered bound) // BEFORE decodeReply. This is what unlocks the meta-driven // slot-walk: the decoder can only validate per-arg if it @@ -498,11 +546,28 @@ export async function render(Component, props = {}, options = {}) { `reason=${error.reason}`, error.original ); - const httpHeaders = getContext(HTTP_HEADERS); - if (httpHeaders) { - httpHeaders.set("x-react-server-action-error", error.reason); - } + // Mirror the canonical setter pattern from + // server/http-headers.mjs — create the Headers object + // on demand AND write it back to the context. The + // prior `if (getContext) set` shape silently dropped + // the header when the context hadn't been initialised + // yet (which can happen when the catch fires before + // any other code path has touched HTTP_HEADERS). + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + httpHeaders.set("x-react-server-action-error", error.reason); + context$(HTTP_HEADERS, httpHeaders); context$(HTTP_STATUS, { status: 400 }); + } else if (error instanceof CsrfRejectedError) { + // CSRF rejection: log at warn level, mask details in + // the response (don't echo the offending origin in + // the body — clients only need the reason header). + logger?.warn?.( + `Server function CSRF rejected: reason=${error.reason} origin=${error.origin ?? "(missing)"}` + ); + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + httpHeaders.set("x-react-server-action-error", error.reason); + context$(HTTP_HEADERS, httpHeaders); + context$(HTTP_STATUS, { status: 403 }); } else { logger?.error(error); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cffc4ce..319f0cdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1149,6 +1149,9 @@ importers: astring: specifier: ^1.9.0 version: 1.9.0 + busboy: + specifier: ^1.6.0 + version: 1.6.0 cac: specifier: ^6.7.14 version: 6.7.14 diff --git a/test/__test__/csrf.spec.mjs b/test/__test__/csrf.spec.mjs new file mode 100644 index 00000000..da050490 --- /dev/null +++ b/test/__test__/csrf.spec.mjs @@ -0,0 +1,169 @@ +import { hostname, server } from "playground/utils"; +import { beforeAll, describe, expect, test } from "vitest"; + +/** + * Integration tests for CSRF / Origin validation on form-submit + * action POSTs (`server.csrf`). + * + * The fixture page does nothing special; the spec sends raw + * multipart POSTs with a `$ACTION_ID_` field so the runtime + * enters its action-dispatch block. The CSRF check fires before + * any token decryption, so we don't need a valid token — we just + * need the request to be action-shaped. + * + * "Passes CSRF" doesn't mean the request succeeded end-to-end — + * the bogus action token causes the dispatch to fall through to + * page render. We assert `status !== 403` and the absence of the + * CSRF error header. + * + * Standalone unit verification of the `checkCsrf` resolver covers + * Referer fallback, opaque `Origin: null`, regex `allowedOrigins` + * entries, and CORS-set contribution — that runs against the + * helper directly, separate from this Playwright-driven spec. + */ + +const FIXTURE = "fixtures/csrf-action.jsx"; + +function buildActionMultipart() { + // Fake action token. CSRF check fires before decryption, so the + // token doesn't need to be valid for this test. + const fakeToken = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const boundary = "rs-csrf-" + Math.random().toString(36).slice(2); + const body = Buffer.concat([ + Buffer.from(`--${boundary}\r\n`, "utf8"), + Buffer.from( + `Content-Disposition: form-data; name="$ACTION_ID_${fakeToken}"\r\n\r\n`, + "utf8" + ), + Buffer.from("\r\n", "utf8"), + Buffer.from(`--${boundary}\r\n`, "utf8"), + Buffer.from(`Content-Disposition: form-data; name="hello"\r\n\r\n`, "utf8"), + Buffer.from("world\r\n", "utf8"), + Buffer.from(`--${boundary}--\r\n`, "utf8"), + ]); + return { + body, + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +async function postForm(headers = {}) { + const { body, contentType } = buildActionMultipart(); + return fetch(hostname, { + method: "POST", + body, + headers: { "content-type": contentType, ...headers }, + }); +} + +describe("server.csrf — default (lax) mode", () => { + beforeAll(async () => { + await server(FIXTURE); + }); + + test("same-origin form post passes (Origin matches server)", async () => { + const url = new URL(hostname); + const res = await postForm({ origin: url.origin }); + expect(res.status).not.toBe(403); + expect(res.headers.get("x-react-server-action-error")).not.toBe( + "csrf_origin_mismatch" + ); + }); + + test("cross-origin form post is rejected with 403", async () => { + const res = await postForm({ origin: "https://evil.example.com" }); + expect(res.status).toBe(403); + expect(res.headers.get("x-react-server-action-error")).toBe( + "csrf_origin_mismatch" + ); + }); + + test("missing Origin/Referer is allowed in lax mode", async () => { + const res = await postForm({}); + expect(res.status).not.toBe(403); + }); +}); + +describe("server.csrf — allowedOrigins", () => { + beforeAll(async () => { + await server(FIXTURE, { + initialConfig: { + server: { + csrf: { allowedOrigins: ["https://host.example.com"] }, + }, + }, + }); + }); + + test("cross-origin allowed via allowedOrigins entry", async () => { + const res = await postForm({ origin: "https://host.example.com" }); + expect(res.status).not.toBe(403); + }); + + test("origin not in allowedOrigins still rejected", async () => { + const res = await postForm({ origin: "https://other.example.com" }); + expect(res.status).toBe(403); + }); +}); + +describe("server.csrf — strict mode", () => { + beforeAll(async () => { + await server(FIXTURE, { + initialConfig: { server: { csrf: { mode: "strict" } } }, + }); + }); + + test("missing Origin is rejected in strict mode", async () => { + const res = await postForm({}); + expect(res.status).toBe(403); + expect(res.headers.get("x-react-server-action-error")).toBe( + "csrf_origin_missing" + ); + }); + + test("matching Origin passes in strict mode", async () => { + const url = new URL(hostname); + const res = await postForm({ origin: url.origin }); + expect(res.status).not.toBe(403); + }); +}); + +describe("server.csrf — disabled", () => { + beforeAll(async () => { + await server(FIXTURE, { + initialConfig: { server: { csrf: false } }, + }); + }); + + test("csrf: false disables the check entirely", async () => { + const res = await postForm({ origin: "https://evil.example.com" }); + expect(res.status).not.toBe(403); + }); +}); + +describe("server.csrf — header-based action calls bypass", () => { + beforeAll(async () => { + await server(FIXTURE); + }); + + test("header-based action call is NOT subject to CSRF (preflight-safe)", async () => { + // JS-driven action call: react-server-action header present, + // JSON body. This shape is preflight-required by the browser, + // so CSRF doesn't fire. Even with a hostile Origin, the + // runtime should not return csrf_origin_mismatch — it may fail + // for other reasons (bogus token, etc.), but not on CSRF + // grounds. + const res = await fetch(hostname, { + method: "POST", + body: "[]", + headers: { + "content-type": "text/plain", + "react-server-action": "fake-token", + origin: "https://evil.example.com", + }, + }); + expect(res.headers.get("x-react-server-action-error")).not.toBe( + "csrf_origin_mismatch" + ); + }); +}); diff --git a/test/__test__/file-upload.spec.mjs b/test/__test__/file-upload.spec.mjs new file mode 100644 index 00000000..c4b90a53 --- /dev/null +++ b/test/__test__/file-upload.spec.mjs @@ -0,0 +1,235 @@ +import { hostname, page, server, waitForHydration } from "playground/utils"; +import { createHash } from "node:crypto"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; + +/** + * End-to-end content-fidelity tests for file uploads. + * + * The existing server-function-validation suite covers the *shape* + * of file uploads (slot-walk validation, MIME / size rejection), + * but only checks `size` / `type` metadata — never the bytes. This + * spec proves that file *contents* survive intact through the whole + * stack: + * + * browser FormData → multipart wire → middleware → + * request.formData() → createFunction slot-walk → handler + * + * The fixture's client buttons construct each File from a + * deterministic xorshift sequence (`generateBytes(seed, len)`). This + * spec mirrors that function so it can compute the expected SHA-256 + * digest from the same byte source the browser sent. The server + * function returns the digest it computed over the received bytes, + * and we assert equality. + * + * Two passes: + * + * 1. Plain runtime path: no `server.multipart.*` config, body is + * consumed via `Request.formData()` (undici's parser). + * 2. Multipart-cap path: `server.multipart.*` configured to a + * generous ceiling so busboy parses, FormData is rebuilt, and + * the renderer reads from the rebuilt body. Same fidelity + * assertions — verifies that swapping parsers preserves bytes. + */ + +// Mirror of fixtures/file-upload-client.jsx::generateBytes. Keep in +// sync with that function — both must produce the same sequence for +// a given (seed, len). +function generateBytes(seed, len) { + const out = new Uint8Array(len); + let s = seed | 0 || 1; + for (let i = 0; i < len; i++) { + s ^= s << 13; + s ^= s >>> 17; + s ^= s << 5; + out[i] = s & 0xff; + } + return out; +} + +function expectedSha256(seed, len) { + return createHash("sha256").update(generateBytes(seed, len)).digest("hex"); +} + +const SHA256_EMPTY = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +const result = () => + page.evaluate(() => window.__react_server_result__ ?? null); + +async function clickAndAwaitResult(testid) { + await page.evaluate(() => { + window.__react_server_result__ = undefined; + }); + await page.getByTestId(testid).click(); + await page.waitForFunction( + () => window.__react_server_result__ !== undefined, + null, + { timeout: 10_000 } + ); + return result(); +} + +describe("file-upload — platform parser path", () => { + beforeAll(async () => { + await server("fixtures/file-upload.jsx"); + }); + + beforeEach(async () => { + await page.goto(hostname); + await waitForHydration(); + }); + + test("validated single file: small payload bytes survive", async () => { + const r = await clickAndAwaitResult("u-validated-small"); + expect(r).toMatchObject({ + kind: "ok", + name: "small.bin", + type: "application/octet-stream", + size: 16, + sha256: expectedSha256(101, 16), + }); + }); + + test("validated single file: 64 KiB payload bytes survive (chunked encoding)", async () => { + const r = await clickAndAwaitResult("u-validated-large"); + expect(r).toMatchObject({ + kind: "ok", + name: "big.bin", + size: 64 * 1024, + sha256: expectedSha256(202, 64 * 1024), + }); + }); + + test("validated single file: empty payload (0 bytes)", async () => { + const r = await clickAndAwaitResult("u-validated-empty"); + expect(r).toMatchObject({ + kind: "ok", + name: "empty.bin", + size: 0, + sha256: SHA256_EMPTY, + }); + }); + + test("validated single file: UTF-8 filename + custom MIME round-trip", async () => { + const r = await clickAndAwaitResult("u-validated-utf8-name"); + expect(r).toMatchObject({ + kind: "ok", + name: "ファイル-π.dat", + type: "application/x-react-server-test", + size: 32, + sha256: expectedSha256(303, 32), + }); + }); + + test("bare 'use server' upload: multiple files + text field, all survive", async () => { + const r = await clickAndAwaitResult("u-bare-multi"); + expect(r?.kind).toBe("ok"); + expect(r.entries).toHaveLength(3); + + const caption = r.entries.find((e) => e.kind === "field"); + expect(caption).toMatchObject({ + name: "caption", + kind: "field", + value: "hello world", + }); + + const files = r.entries.filter((e) => e.kind === "file"); + expect(files).toHaveLength(2); + expect(files[0]).toMatchObject({ + name: "files", + filename: "f1.bin", + size: 24, + sha256: expectedSha256(404, 24), + }); + expect(files[1]).toMatchObject({ + name: "files", + filename: "f2.bin", + size: 48, + sha256: expectedSha256(505, 48), + }); + }); + + test("validated mixed: two files + text, each surfaces with its own bytes", async () => { + const r = await clickAndAwaitResult("u-mixed"); + expect(r).toMatchObject({ + kind: "ok", + caption: "two files plus text", + a: { + name: "a.bin", + size: 100, + sha256: expectedSha256(606, 100), + }, + b: { + name: "b.bin", + size: 200, + sha256: expectedSha256(707, 200), + }, + }); + }); +}); + +describe("file-upload — multipart-cap (busboy) path", () => { + beforeAll(async () => { + await server("fixtures/file-upload.jsx", { + initialConfig: { + server: { + multipart: { + // Ceilings well above any test payload — we're verifying + // bytes-survival through the busboy parser, not cap + // enforcement (that's covered by http-multipart-cap.spec). + maxFileSize: 1 * 1024 * 1024, + maxFieldSize: 64 * 1024, + maxFiles: 10, + maxFields: 100, + maxParts: 50, + maxFieldNameSize: 200, + }, + }, + }, + }); + }); + + beforeEach(async () => { + await page.goto(hostname); + await waitForHydration(); + }); + + test("64 KiB file digest matches through busboy parse", async () => { + const r = await clickAndAwaitResult("u-validated-large"); + expect(r).toMatchObject({ + kind: "ok", + name: "big.bin", + size: 64 * 1024, + sha256: expectedSha256(202, 64 * 1024), + }); + }); + + test("UTF-8 filename + custom MIME survive busboy parse", async () => { + const r = await clickAndAwaitResult("u-validated-utf8-name"); + expect(r).toMatchObject({ + kind: "ok", + name: "ファイル-π.dat", + type: "application/x-react-server-test", + size: 32, + sha256: expectedSha256(303, 32), + }); + }); + + test("multi-file mixed bare upload through busboy", async () => { + const r = await clickAndAwaitResult("u-bare-multi"); + expect(r?.kind).toBe("ok"); + const files = r.entries.filter((e) => e.kind === "file"); + expect(files[0].sha256).toBe(expectedSha256(404, 24)); + expect(files[1].sha256).toBe(expectedSha256(505, 48)); + }); + + test("validated mixed: two files + text through busboy", async () => { + const r = await clickAndAwaitResult("u-mixed"); + expect(r).toMatchObject({ + kind: "ok", + caption: "two files plus text", + a: { sha256: expectedSha256(606, 100) }, + b: { sha256: expectedSha256(707, 200) }, + }); + }); +}); diff --git a/test/__test__/http-body-cap.spec.mjs b/test/__test__/http-body-cap.spec.mjs new file mode 100644 index 00000000..27808255 --- /dev/null +++ b/test/__test__/http-body-cap.spec.mjs @@ -0,0 +1,140 @@ +import { hostname, server } from "playground/utils"; +import { Readable } from "node:stream"; +import { describe, expect, test } from "vitest"; + +/** + * Integration tests for the HTTP-layer body-size cap + * (`server.maxBodyBytes`). + * + * The cap is enforced inside `createMiddleware` (the Node adapter) + * and is plumbed there from `config.server.maxBodyBytes` by the dev + * and production bootstraps. We drive the cap via `initialConfig` + * so each test exercises the real config → middleware → handler + * pipeline rather than poking the middleware in isolation. + * + * The fixture's `init$` returns a middleware that intercepts POSTs, + * reads `request.arrayBuffer()` (going through the body-cap + * Transform when the cap is enabled), and replies with a plain + * `received:` text Response. That puts a real body-reading + * consumer in front of the wrapper without going through the + * framework's POST → remote-props decode path. + */ + +const FIXTURE = "fixtures/body-cap.jsx"; + +async function postBody(body, headers = {}) { + return fetch(hostname, { method: "POST", body, headers }); +} + +describe("server.maxBodyBytes — under cap", () => { + test("POST below cap delivers the full body to the handler", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 1024 } }, + }); + const res = await postBody("hello world", { + "content-type": "text/plain", + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("received:11"); + }); + + test("empty POST (Content-Length: 0) skips the wrapper and echoes 0", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 1024 } }, + }); + // node-fetch sends Content-Length: 0 for an empty string body — + // exactly the "explicit empty body" case the gate should skip. + const res = await postBody("", { "content-type": "text/plain" }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("received:0"); + }); +}); + +describe("server.maxBodyBytes — over cap", () => { + test("declared Content-Length over cap → 413, body never read", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 32 } }, + }); + const res = await postBody("x".repeat(64), { + "content-type": "text/plain", + }); + expect(res.status).toBe(413); + }); + + test("streaming body over cap (chunked, no Content-Length) is rejected", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 32 } }, + }); + // Chunked POST (no Content-Length) — exercises the streaming + // wrapper, not the cheap declared-length pre-check. When the + // wrapper Transform overflows it destroys the source socket + // immediately to bound resource usage; the cap explicitly does + // NOT try to read the rest of the attacker-controlled payload + // just to deliver a courtesy 413, since that would defeat the + // defense. The connection close surfaces on the client side + // as a fetch error (UND_ERR_SOCKET / ECONNRESET). + // + // Honest large uploads with a declared Content-Length take a + // separate, earlier path that *does* deliver a clean 413 + // (covered by the test above). This test asserts the *defense + // properties* of the streaming path: the request was rejected + // (either via 413 if Node managed to flush before close, or + // via socket error if it didn't). What matters is that the + // server did not return a 200 — the cap was effective. + const chunks = []; + for (let i = 0; i < 10; i++) chunks.push(Buffer.alloc(10, "x")); + const webStream = Readable.toWeb(Readable.from(chunks)); + let status = null; + let fetchError = null; + try { + const res = await fetch(hostname, { + method: "POST", + body: webStream, + duplex: "half", + headers: { "content-type": "application/octet-stream" }, + }); + status = res.status; + } catch (e) { + fetchError = e; + } + // Either we got a 413 (best case) or fetch failed with a + // socket-level error (acceptable — connection closed during + // streaming overflow). What we explicitly reject is a 200, + // which would mean the handler successfully processed the + // oversized payload. + if (status !== null) { + expect(status).toBe(413); + } else { + expect(fetchError).toBeTruthy(); + expect(fetchError.cause?.code ?? fetchError.code).toMatch( + /UND_ERR_SOCKET|ECONNRESET/ + ); + } + }); +}); + +describe("server.maxBodyBytes — bypass conditions", () => { + test("GET ignores the cap regardless of header values", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 1 } }, + }); + const res = await fetch(hostname, { + method: "GET", + headers: { "content-length": "999999999" }, + }); + expect(res.status).toBe(200); + // GET falls through `init$` to the page render. + expect(await res.text()).toContain("idle"); + }); + + test("maxBodyBytes: 0 disables the cap", async () => { + await server(FIXTURE, { + initialConfig: { server: { maxBodyBytes: 0 } }, + }); + const res = await postBody("x".repeat(1024), { + "content-type": "text/plain", + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("received:1024"); + }); +}); diff --git a/test/__test__/http-multipart-cap.spec.mjs b/test/__test__/http-multipart-cap.spec.mjs new file mode 100644 index 00000000..4c6b3d7a --- /dev/null +++ b/test/__test__/http-multipart-cap.spec.mjs @@ -0,0 +1,244 @@ +import { hostname, server } from "playground/utils"; +import { describe, expect, test } from "vitest"; + +/** + * Integration tests for the streaming multipart cap + * (`server.multipart.*`). + * + * The cap defends against attacks that `server.maxBodyBytes` + * cannot bound: + * + * - **High-cardinality**: 1M small fields fit inside any + * reasonable body cap, but the platform parser still allocates + * 1M FormData entries. `maxFields` / `maxParts` cap that. + * - **Long field names**: a single field with a 1 MiB name has + * small wire bytes but allocates a 1 MiB string. + * `maxFieldNameSize` catches it. + * - **File-as-field smuggling**: a large blob without + * `filename=` bypasses any downstream `file()` size policy. + * `maxFieldSize` catches it. + * + * The fixture's `init$` reads the FormData and echoes the entries + * as JSON, so we can verify both: + * + * 1. **A/B equivalence**: with caps configured, the FormData + * busboy produces matches what the platform parser would have + * produced (filename, type, size, hex-prefix of bytes — the + * properties react-server's consumers actually rely on). + * 2. **Per-cap rejection**: requests breaching any limit return + * HTTP 413 before the handler runs (no echoed entries). + */ + +const FIXTURE = "fixtures/multipart-cap.jsx"; + +/** + * Build a minimal multipart/form-data body for a list of parts. + * `value` is treated as the raw body bytes after CRLF; pass a + * string for fields and either a string or Buffer for files. + */ +function buildMultipart(parts) { + const boundary = "------rs-test-" + Math.random().toString(36).slice(2); + const chunks = []; + for (const p of parts) { + let header = `--${boundary}\r\n`; + if (p.filename != null) { + header += `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`; + header += `Content-Type: ${p.type ?? "application/octet-stream"}\r\n`; + } else { + header += `Content-Disposition: form-data; name="${p.name}"\r\n`; + } + header += "\r\n"; + chunks.push(Buffer.from(header, "utf8")); + chunks.push( + Buffer.isBuffer(p.value) ? p.value : Buffer.from(p.value, "utf8") + ); + chunks.push(Buffer.from("\r\n", "utf8")); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf8")); + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +async function postMultipart(parts) { + const { body, contentType } = buildMultipart(parts); + return fetch(hostname, { + method: "POST", + body, + headers: { "content-type": contentType }, + }); +} + +describe("server.multipart — A/B equivalence with platform parser", () => { + test("busboy-parsed FormData matches platform-parsed shape", async () => { + // First pass: NO multipart caps configured. The fixture's + // init$ reads FormData via the platform parser. + await server(FIXTURE); + let platformOutput; + { + const res = await postMultipart([ + { name: "alpha", value: "first-value" }, + { name: "beta", value: "second-value" }, + { + name: "upload", + filename: "doc.txt", + type: "text/plain", + value: "hello world", + }, + { + name: "binary", + filename: "blob.bin", + type: "application/octet-stream", + value: Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03]), + }, + ]); + expect(res.status).toBe(200); + platformOutput = await res.json(); + } + + // Second pass: multipart caps configured (high enough to not + // trip), so init$ reads FormData via busboy. + await server(FIXTURE, { + initialConfig: { + server: { + multipart: { + maxFileSize: 10 * 1024, + maxFieldSize: 10 * 1024, + maxFiles: 10, + maxFields: 10, + maxParts: 20, + maxFieldNameSize: 100, + }, + }, + }, + }); + let busboyOutput; + { + const res = await postMultipart([ + { name: "alpha", value: "first-value" }, + { name: "beta", value: "second-value" }, + { + name: "upload", + filename: "doc.txt", + type: "text/plain", + value: "hello world", + }, + { + name: "binary", + filename: "blob.bin", + type: "application/octet-stream", + value: Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03]), + }, + ]); + expect(res.status).toBe(200); + busboyOutput = await res.json(); + } + + // Compare entry-by-entry on the properties react-server's + // consumers actually rely on: name, kind, value/filename/type/ + // size, and the hex-prefix of file contents. + expect(busboyOutput).toEqual(platformOutput); + }); +}); + +describe("server.multipart — per-cap rejections", () => { + test("maxFileSize: oversize file part → 413", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFileSize: 8 } }, + }, + }); + const res = await postMultipart([ + { + name: "upload", + filename: "big.txt", + value: "this is more than 8 bytes", + }, + ]); + expect(res.status).toBe(413); + }); + + test("maxFieldSize: oversize text field → 413 (file-as-field smuggling defence)", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFieldSize: 8 } }, + }, + }); + // Note: NO `filename=` — submitted as a plain field. Without + // maxFieldSize this would slip past any downstream `file()` + // size policy. With maxFieldSize, the cap fires. + const res = await postMultipart([ + { name: "smuggled", value: "this is more than 8 bytes" }, + ]); + expect(res.status).toBe(413); + }); + + test("maxFields: too many fields → 413", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFields: 3 } }, + }, + }); + const parts = []; + for (let i = 0; i < 10; i++) parts.push({ name: `f${i}`, value: `v${i}` }); + const res = await postMultipart(parts); + expect(res.status).toBe(413); + }); + + test("maxFiles: too many file parts → 413", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFiles: 2 } }, + }, + }); + const parts = []; + for (let i = 0; i < 5; i++) { + parts.push({ name: `file${i}`, filename: `f${i}.txt`, value: "x" }); + } + const res = await postMultipart(parts); + expect(res.status).toBe(413); + }); + + test("maxFieldNameSize: long field name → 413", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFieldNameSize: 5 } }, + }, + }); + const res = await postMultipart([ + { name: "a-name-longer-than-five", value: "x" }, + ]); + expect(res.status).toBe(413); + }); +}); + +describe("server.multipart — bypass conditions", () => { + test("no caps configured → platform parser, all entries delivered", async () => { + await server(FIXTURE); + const parts = []; + for (let i = 0; i < 50; i++) parts.push({ name: `f${i}`, value: `v${i}` }); + const res = await postMultipart(parts); + expect(res.status).toBe(200); + const out = await res.json(); + expect(out).toHaveLength(50); + }); + + test("caps configured but request is not multipart → cap doesn't apply", async () => { + await server(FIXTURE, { + initialConfig: { + server: { multipart: { maxFieldSize: 1 } }, + }, + }); + // Plain text/plain POST — never enters the multipart branch. + // The fixture's init$ calls request.formData() which throws on + // non-multipart content-types; we get a non-413 result, which + // is the correct signal that the multipart cap was bypassed. + const res = await fetch(hostname, { + method: "POST", + body: "plain text body, well above 1 byte", + headers: { "content-type": "text/plain" }, + }); + expect(res.status).not.toBe(413); + }); +}); diff --git a/test/__test__/server-function-validation.spec.mjs b/test/__test__/server-function-validation.spec.mjs index f3cb4218..dbf7cc69 100644 --- a/test/__test__/server-function-validation.spec.mjs +++ b/test/__test__/server-function-validation.spec.mjs @@ -1,5 +1,5 @@ import { hostname, page, server, waitForHydration } from "playground/utils"; -import { expect, test } from "vitest"; +import { beforeAll, beforeEach, expect, test } from "vitest"; /** * Runtime-level E2E for `createFunction` slot-walk validation. @@ -28,6 +28,18 @@ import { expect, test } from "vitest"; * so the assertions read a consistent shape regardless of path. */ +// Boot the fixture server once for the whole file. Each test still +// starts with a fresh hydrated page via the beforeEach below; this +// removes ~30 dev-server cold starts (one per test) from the suite. +beforeAll(async () => { + await server("fixtures/server-function-validation.jsx"); +}); + +beforeEach(async () => { + await page.goto(hostname); + await waitForHydration(); +}); + const result = () => page.evaluate(() => window.__react_server_result__ ?? null); @@ -47,10 +59,6 @@ async function clickAndAwaitResult(testid) { } test("createFunction slot-walk validation — happy path", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-greet-ok"); expect(r).toMatchObject({ kind: "ok", @@ -61,46 +69,26 @@ test("createFunction slot-walk validation — happy path", async () => { }); test("createFunction rejects bad slot-0 type before handler runs", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-greet-bad-arg-0"); expect(r?.kind).toBe("clientError"); }); test("createFunction rejects bad slot-1 type", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-greet-bad-arg-1"); expect(r?.kind).toBe("clientError"); }); test("parse.args runs before validate.args (string → number coercion)", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-parsed-number-ok"); expect(r).toMatchObject({ kind: "ok", n: 42, handlerRan: true }); }); test("parse.args producing NaN fails validate.args", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-parsed-number-bad"); expect(r?.kind).toBe("clientError"); }); test("validation failure: handler must not run (no server-side side effect)", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - // Reset shared marker. await clickAndAwaitResult("v-reset-side-effect"); @@ -114,10 +102,6 @@ test("validation failure: handler must not run (no server-side side effect)", as }); test("formData upload — happy path", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-upload-ok"); expect(r).toMatchObject({ kind: "ok", @@ -129,37 +113,21 @@ test("formData upload — happy path", async () => { }); test("formData upload — oversize file rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-upload-oversize"); expect(r?.kind).toBe("clientError"); }); test("formData upload — wrong MIME rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-upload-bad-mime"); expect(r?.kind).toBe("clientError"); }); test("formData upload — injected unknown key rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-upload-injected"); expect(r?.kind).toBe("clientError"); }); test("bare 'use server' export without createFunction works unchanged", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); - const r = await clickAndAwaitResult("v-bare-echo"); expect(r).toMatchObject({ kind: "ok", @@ -177,9 +145,6 @@ test("bare 'use server' export without createFunction works unchanged", async () // handler observes a `drainError` set by the wrapped consumer). test("arrayBuffer — happy path", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-ab-ok"); expect(r).toMatchObject({ kind: "ok", @@ -191,17 +156,11 @@ test("arrayBuffer — happy path", async () => { }); test("arrayBuffer — oversize rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-ab-oversize"); expect(r?.kind).toBe("clientError"); }); test("typedArray — happy path with declared ctor", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-ta-ok"); expect(r).toMatchObject({ kind: "ok", @@ -213,25 +172,16 @@ test("typedArray — happy path with declared ctor", async () => { }); test("typedArray — wrong ctor rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-ta-bad-ctor"); expect(r?.kind).toBe("clientError"); }); test("typedArray — oversize rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-ta-oversize"); expect(r?.kind).toBe("clientError"); }); test("map — happy path with inner key/value schemas", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-map-ok"); expect(r).toMatchObject({ kind: "ok", @@ -242,25 +192,16 @@ test("map — happy path with inner key/value schemas", async () => { }); test("map — oversize rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-map-oversize"); expect(r?.kind).toBe("clientError"); }); test("map — bad inner value rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-map-bad-value"); expect(r?.kind).toBe("clientError"); }); test("set — happy path", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-set-ok"); expect(r).toMatchObject({ kind: "ok", @@ -271,17 +212,11 @@ test("set — happy path", async () => { }); test("set — oversize rejected", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-set-oversize"); expect(r?.kind).toBe("clientError"); }); test("stream — drains within cap", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-stream-under-cap"); expect(r).toMatchObject({ kind: "ok", @@ -292,9 +227,6 @@ test("stream — drains within cap", async () => { }); test("stream — wrapped consumer errors past maxChunks", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-stream-over-cap"); // The handler runs (validation gates only the slot's wire shape, not // chunk contents), but its `drainError` must be set when the wrapper @@ -306,9 +238,6 @@ test("stream — wrapped consumer errors past maxChunks", async () => { }); test("asyncIterable — yields within cap and inner schema", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-aiter-ok"); expect(r).toMatchObject({ kind: "ok", @@ -318,27 +247,18 @@ test("asyncIterable — yields within cap and inner schema", async () => { }); test("asyncIterable — over-yield surfaces as drainError", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-aiter-overyield"); expect(r?.kind).toBe("ok"); expect(r?.drainError).toMatch(/max_yields_exceeded/); }); test("asyncIterable — bad-value yield surfaces as drainError", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-aiter-bad-value"); expect(r?.kind).toBe("ok"); expect(r?.drainError).toMatch(/validate_failed/); }); test("iterable — sync iteration with caps", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-iter-ok"); expect(r).toMatchObject({ kind: "ok", @@ -348,18 +268,12 @@ test("iterable — sync iteration with caps", async () => { }); test("iterable — over-yield surfaces as drainError", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-iter-overyield"); expect(r?.kind).toBe("ok"); expect(r?.drainError).toMatch(/max_yields_exceeded/); }); test("promise — resolves through inner schema", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-promise-ok"); expect(r).toMatchObject({ kind: "ok", @@ -369,9 +283,6 @@ test("promise — resolves through inner schema", async () => { }); test("promise — bad resolved value surfaces as awaitError", async () => { - await server("fixtures/server-function-validation.jsx"); - await page.goto(hostname); - await waitForHydration(); const r = await clickAndAwaitResult("v-promise-bad-value"); expect(r?.kind).toBe("ok"); expect(r?.awaitError).toMatch(/validate_failed/); diff --git a/test/fixtures/body-cap.jsx b/test/fixtures/body-cap.jsx new file mode 100644 index 00000000..888bde76 --- /dev/null +++ b/test/fixtures/body-cap.jsx @@ -0,0 +1,35 @@ +/** + * Fixture for the HTTP-layer body-size cap (`server.maxBodyBytes`). + * + * `init$` returns an async middleware that runs *before* the + * framework's POST → remote-props decode path. We use it to + * intercept any `POST` against the fixture, read the request body + * via `request.arrayBuffer()` (which goes through the body-cap + * Transform when the cap is enabled), and short-circuit with a + * plain `received:` Response. + * + * The integration spec drives the cap from + * `initialConfig: { server: { maxBodyBytes: ... } }` and asserts: + * + * - declared `Content-Length` over the cap → HTTP 413 + * - chunked body that exceeds the cap mid-stream → HTTP 413 + * - under-cap POST → handler reads full body, echoes byte count + * - empty-body POST (Content-Length: 0) → echoes 0 (no wrapper) + * - GET passes through to the page render (cap doesn't apply) + * - `maxBodyBytes: 0` → cap disabled, oversized payloads pass + */ +export function init$() { + return async (ctx) => { + if (ctx.request.method === "POST") { + const buf = await ctx.request.arrayBuffer(); + return new Response(`received:${buf.byteLength}`, { + headers: { "content-type": "text/plain" }, + }); + } + // Fall through to the page render for GET / HEAD. + }; +} + +export default function BodyCapPage() { + return

idle

; +} diff --git a/test/fixtures/csrf-action.jsx b/test/fixtures/csrf-action.jsx new file mode 100644 index 00000000..19bba8f0 --- /dev/null +++ b/test/fixtures/csrf-action.jsx @@ -0,0 +1,28 @@ +/** + * Fixture for the CSRF integration spec. + * + * The spec sends raw multipart POSTs that LOOK like form-submit + * action requests (`$ACTION_ID_` field). The CSRF check + * fires BEFORE any token decryption, so the test doesn't need a + * valid token — it just needs the request to be action-shaped so + * the runtime enters the action-dispatch block. + * + * - CSRF fails → HTTP 403 with `x-react-server-action-error` + * - CSRF passes → runtime proceeds to action lookup, hits + * "unknown action" path (since the token is garbage), returns + * the rendered page with an error context. We assert on the + * absence of 403, not on a specific success body. + * + * The companion `csrf-actions.mjs` import is required: in + * production the runtime auto-disables server functions when the + * server-reference manifest is empty, which would short-circuit + * past the action-dispatch block (and therefore past the CSRF + * check) for action-shaped POSTs. Pulling in one real + * `"use server"` export keeps the manifest non-empty so the + * dispatch block runs and the CSRF check fires. + */ +import "./csrf-actions.mjs"; + +export default function CsrfActionPage() { + return

csrf-fixture

; +} diff --git a/test/fixtures/csrf-actions.mjs b/test/fixtures/csrf-actions.mjs new file mode 100644 index 00000000..fc525047 --- /dev/null +++ b/test/fixtures/csrf-actions.mjs @@ -0,0 +1,17 @@ +/** + * Companion server-actions module for the CSRF spec. + * + * Why this module exists: the runtime auto-disables server functions + * in production when the server-reference manifest is empty (see + * `serverFunctionsEnabled` in render-rsc.jsx). With no real + * `"use server"` export anywhere in the fixture, an action-shaped + * POST never enters the action-dispatch block — so the CSRF check + * (which lives inside that block) never fires. Importing this + * module from `csrf-action.jsx` is enough to seed the manifest + * with one entry, which is all the runtime checks. + */ +"use server"; + +export async function noop() { + return null; +} diff --git a/test/fixtures/file-upload-actions.mjs b/test/fixtures/file-upload-actions.mjs new file mode 100644 index 00000000..4fc129d5 --- /dev/null +++ b/test/fixtures/file-upload-actions.mjs @@ -0,0 +1,111 @@ +"use server"; + +import { createFunction, file, formData } from "@lazarv/react-server/function"; + +/** + * Server actions for the file-upload integration spec. + * + * Each action reads the uploaded file(s), computes a SHA-256 hex + * digest of the bytes via Web Crypto (available globally in Node 20+), + * and returns the digest plus filename / type / size metadata. The + * spec recomputes the digest from the same byte source it sent and + * asserts equality — proving the bytes survived the full round-trip + * through the multipart wire, the WHATWG Request, and (when + * `server.multipart.*` is configured) the busboy-driven streaming + * parse. + */ + +// Minimal Standard-Schema duck-type for string fields, matching the +// inline pattern in server-function-validation-actions.mjs. Keeps +// the fixture dep-light — `safeValidate` in the runtime accepts any +// object with `safeParse` / `assert` / `parse`. +function strSchema() { + return { + safeParse(v) { + if (typeof v === "string") return { success: true, data: v }; + return { success: false, error: { message: "expected string" } }; + }, + }; +} + +async function sha256Hex(blobOrBuffer) { + const buf = + blobOrBuffer instanceof ArrayBuffer + ? blobOrBuffer + : await blobOrBuffer.arrayBuffer(); + const hash = await crypto.subtle.digest("SHA-256", buf); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +// ─── Single-file upload via createFunction's formData() / file() spec ── +// +// Validates only the shape of the upload (a single `photo` file, no +// extra fields). The maxBytes is generous so the spec can drive +// content-fidelity scenarios up to 256 KiB; per-byte limits aren't +// what's under test here — bytes-arrived-intact is. +export const uploadValidated = createFunction([ + formData({ + photo: file({ maxBytes: 256 * 1024 }), + }), +])(async function uploadValidated(form) { + const photo = form.get("photo"); + return { + kind: "ok", + name: photo.name, + type: photo.type, + size: photo.size, + sha256: await sha256Hex(photo), + }; +}); + +// ─── Bare "use server" upload — receives raw FormData, no slot-walk +// validation. Proves that the platform `request.formData()` path +// (no createFunction wrapper) still surfaces files with intact +// bytes. This is the "back-compat" channel for users who haven't +// adopted the validated wrappers. +export async function uploadBare(form) { + const out = []; + for (const [name, value] of form.entries()) { + if (typeof value === "string") { + out.push({ name, kind: "field", value }); + } else { + out.push({ + name, + kind: "file", + filename: value.name, + type: value.type, + size: value.size, + sha256: await sha256Hex(value), + }); + } + } + return { kind: "ok", entries: out }; +} + +// ─── Multi-file + mixed-fields upload ───────────────────────────────── +// +// Validates a FormData with exactly two files (`a`, `b`) plus a +// `caption` text field. Exercises: +// +// - multiple file entries in a single multipart body +// - text + binary entries mixed together +// - per-entry bytes survive independently +// - field ordering + value preservation through the wire +export const uploadMixed = createFunction([ + formData({ + caption: strSchema(), + a: file({ maxBytes: 256 * 1024 }), + b: file({ maxBytes: 256 * 1024 }), + }), +])(async function uploadMixed(form) { + const a = form.get("a"); + const b = form.get("b"); + return { + kind: "ok", + caption: form.get("caption"), + a: { name: a.name, type: a.type, size: a.size, sha256: await sha256Hex(a) }, + b: { name: b.name, type: b.type, size: b.size, sha256: await sha256Hex(b) }, + }; +}); diff --git a/test/fixtures/file-upload-client.jsx b/test/fixtures/file-upload-client.jsx new file mode 100644 index 00000000..5299adf6 --- /dev/null +++ b/test/fixtures/file-upload-client.jsx @@ -0,0 +1,128 @@ +"use client"; + +import { + uploadBare, + uploadMixed, + uploadValidated, +} from "./file-upload-actions.mjs"; + +/** + * Driver for the file-upload integration spec. Each button + * constructs a `File` from a deterministic byte pattern, calls the + * matching server action, and stashes the result on + * `window.__react_server_result__`. The spec recomputes the digest + * from the same byte source it constructed the File with and asserts + * equality. + * + * The byte patterns come from a small PRNG-ish helper + * (`generateBytes`) so the same `(seed, len)` always produces the + * same buffer — the spec uses that to produce its expected digest. + */ + +// xorshift32-style sequence — deterministic, fast, good enough for +// content-fidelity checks (NOT for crypto). The spec mirrors this +// exact function so it can compute the expected SHA-256 over the +// same bytes. +function generateBytes(seed, len) { + const out = new Uint8Array(len); + let s = seed | 0 || 1; + for (let i = 0; i < len; i++) { + s ^= s << 13; + s ^= s >>> 17; + s ^= s << 5; + out[i] = s & 0xff; + } + return out; +} + +function makeFile(seed, len, name, type) { + const bytes = generateBytes(seed, len); + return new File([bytes], name, { type }); +} + +export default function FileUploadClient() { + const call = (testid, run) => ( + + ); + + return ( +
+ {call("u-validated-small", () => { + const fd = new FormData(); + fd.set( + "photo", + makeFile(101, 16, "small.bin", "application/octet-stream") + ); + return uploadValidated(fd); + })} + + {call("u-validated-large", () => { + const fd = new FormData(); + // 64 KiB — non-trivial size, exercises chunked multipart + // encoding through the wire. + fd.set( + "photo", + makeFile(202, 64 * 1024, "big.bin", "application/octet-stream") + ); + return uploadValidated(fd); + })} + + {call("u-validated-empty", () => { + const fd = new FormData(); + fd.set( + "photo", + new File([], "empty.bin", { type: "application/octet-stream" }) + ); + return uploadValidated(fd); + })} + + {call("u-validated-utf8-name", () => { + const fd = new FormData(); + // Non-ASCII filename + custom MIME — both must round-trip. + fd.set( + "photo", + makeFile(303, 32, "ファイル-π.dat", "application/x-react-server-test") + ); + return uploadValidated(fd); + })} + + {call("u-bare-multi", () => { + const fd = new FormData(); + fd.set("caption", "hello world"); + fd.append( + "files", + makeFile(404, 24, "f1.bin", "application/octet-stream") + ); + fd.append( + "files", + makeFile(505, 48, "f2.bin", "application/octet-stream") + ); + return uploadBare(fd); + })} + + {call("u-mixed", () => { + const fd = new FormData(); + fd.set("caption", "two files plus text"); + fd.set("a", makeFile(606, 100, "a.bin", "application/octet-stream")); + fd.set("b", makeFile(707, 200, "b.bin", "application/octet-stream")); + return uploadMixed(fd); + })} +
+ ); +} diff --git a/test/fixtures/file-upload.jsx b/test/fixtures/file-upload.jsx new file mode 100644 index 00000000..ae7cf948 --- /dev/null +++ b/test/fixtures/file-upload.jsx @@ -0,0 +1,10 @@ +import FileUploadClient from "./file-upload-client.jsx"; + +/** + * Page fixture for the file-upload integration spec. Mounts the + * client driver — see file-upload-client.jsx for the actual buttons + * and file-upload-actions.mjs for the server actions. + */ +export default function FileUploadPage() { + return ; +} diff --git a/test/fixtures/multipart-cap.jsx b/test/fixtures/multipart-cap.jsx new file mode 100644 index 00000000..eeb0b024 --- /dev/null +++ b/test/fixtures/multipart-cap.jsx @@ -0,0 +1,58 @@ +/** + * Fixture for the streaming multipart cap (`server.multipart.*`). + * + * `init$` returns an async middleware that intercepts every POST, + * reads the request body via `request.formData()` (which goes + * through the busboy-driven cap when `server.multipart.*` limits + * are configured), serializes the resulting FormData entries to + * JSON, and returns them as the response body. The integration + * spec asserts both: + * + * - busboy-parsed FormData is structurally equivalent to what + * `Request.formData()` would have produced (A/B equivalence) + * - per-cap rejections (`maxFileSize`, `maxFields`, + * `maxFieldNameSize`, etc.) return HTTP 413 *before* the + * handler runs (proven by checking that the response body is + * empty / not the echoed entries) + * + * The page render path is only used as a GET-fallback; all the + * multipart-cap behaviour is exercised via init$. + */ +export function init$() { + return async (ctx) => { + if (ctx.request.method === "POST") { + const fd = await ctx.request.formData(); + const out = []; + for (const [name, value] of fd.entries()) { + if (typeof value === "string") { + out.push({ name, kind: "field", value }); + } else { + // File / Blob entry. `name` here is the form field name, + // `value.name` is the filename from Content-Disposition. + const buf = new Uint8Array(await value.arrayBuffer()); + out.push({ + name, + kind: "file", + filename: value.name, + type: value.type, + size: value.size, + // Hex-encode the first few bytes so the spec can verify + // file contents survived the parse without leaking + // binary into the JSON response. + head: Array.from(buf.slice(0, 16)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + }); + } + } + return new Response(JSON.stringify(out), { + headers: { "content-type": "application/json" }, + }); + } + // Fall through to GET render. + }; +} + +export default function MultipartCapPage() { + return

idle

; +} From 7f4c05cb8c4d8bc1ffdf67840f349f457c07cec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Sat, 16 May 2026 17:50:38 +0200 Subject: [PATCH 2/2] fix: edge multipart cap --- .../pages/en/(pages)/features/http-layer.mdx | 2 +- .../pages/ja/(pages)/features/http-layer.mdx | 2 +- .../adapters/bun/server/entry.mjs | 8 +- .../adapters/deno/server/entry.mjs | 8 +- .../adapters/docker/server/entry.edge.mjs | 8 +- .../adapters/shared/edge-body-caps.mjs | 279 ++++++++++++++++++ .../adapters/shared/edge-handler.mjs | 37 ++- .../react-server/lib/http/multipart-cap.mjs | 58 ++++ packages/react-server/lib/start/edge.mjs | 12 +- packages/react-server/server/action-state.mjs | 13 +- packages/react-server/server/render-rsc.jsx | 2 +- test/__test__/http-body-cap.spec.mjs | 5 + test/__test__/http-multipart-cap.spec.mjs | 7 +- 13 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 packages/react-server/adapters/shared/edge-body-caps.mjs diff --git a/docs/src/pages/en/(pages)/features/http-layer.mdx b/docs/src/pages/en/(pages)/features/http-layer.mdx index 86a1f565..7167c76d 100644 --- a/docs/src/pages/en/(pages)/features/http-layer.mdx +++ b/docs/src/pages/en/(pages)/features/http-layer.mdx @@ -93,7 +93,7 @@ All sub-limits default to `0` (disabled). When *every* sub-limit is disabled, bu 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. -The cap only applies on the Node `createMiddleware` path. Edge / serverless adapters (Cloudflare Workers, Vercel Functions, etc.) have their own platform-level multipart limits and are not affected by this config. +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. ## CSRF / Origin validation diff --git a/docs/src/pages/ja/(pages)/features/http-layer.mdx b/docs/src/pages/ja/(pages)/features/http-layer.mdx index c4d27885..bd24e612 100644 --- a/docs/src/pages/ja/(pages)/features/http-layer.mdx +++ b/docs/src/pages/ja/(pages)/features/http-layer.mdx @@ -93,7 +93,7 @@ export default { パースされた`FormData`はプラットフォームパーサーが生成するものと機能的に同等です(filename、MIMEタイプ、サイズ、バイトが保持されます)。パートごとの`Content-Transfer-Encoding`のみ異なりますが、HTML5仕様は`multipart/form-data`に対してこれを廃止しており、モダンなブラウザは送信しないため、実用上の影響はありません。統合テストスイートのA/B同等性テストがこの特性を保証します。 -この上限はNodeの`createMiddleware`パスにのみ適用されます。エッジ/サーバレスアダプタ(Cloudflare Workers、Vercel Functionsなど)はプラットフォームレベルでマルチパート制限を持っており、この設定の影響を受けません。 +この上限は本ランタイムが提供する全アダプタターゲットに適用されます。Nodeパスは受信リクエストをbusboyで直接消費し、エッジ/サーバレスパスはNodeのWeb Streams相互運用を介してWeb `Request`ボディを同じパーサに適合させます。両パスは同じパーサコアを共有するため、パートごとの上限のセマンティクスは同一です。ボディ上限(`server.maxBodyBytes`)も同様に移植可能で、宣言された`Content-Length`をヘッダーから確認した後、ボディを`maxBodyBytes + 1`まで読み取り、オーバーフローした場合は即座に413を返します。Node互換APIをサポートしないネイティブエッジランタイムでは、パートごとのマルチパート上限は静かにプラットフォームパーサにダウングレードされますが、ボディ上限は引き続き適用されます。 ## CSRF / Originバリデーション diff --git a/packages/react-server/adapters/bun/server/entry.mjs b/packages/react-server/adapters/bun/server/entry.mjs index 87a28d32..4c8f6b78 100644 --- a/packages/react-server/adapters/bun/server/entry.mjs +++ b/packages/react-server/adapters/bun/server/entry.mjs @@ -5,10 +5,14 @@ import { createRequestHandler } from "../../shared/edge-handler.mjs"; export const port = parseInt(process.env.PORT || "3000", 10); export const hostname = process.env.HOST || "0.0.0.0"; -export const { handler } = await reactServer({ +export const { handler, config } = await reactServer({ origin: process.env.ORIGIN || `http://${hostname}:${port}`, outDir: ".", }); export { createContext }; -export const handleRequest = createRequestHandler(handler, createContext); +export const handleRequest = createRequestHandler( + handler, + createContext, + config +); diff --git a/packages/react-server/adapters/deno/server/entry.mjs b/packages/react-server/adapters/deno/server/entry.mjs index 87a28d32..4c8f6b78 100644 --- a/packages/react-server/adapters/deno/server/entry.mjs +++ b/packages/react-server/adapters/deno/server/entry.mjs @@ -5,10 +5,14 @@ import { createRequestHandler } from "../../shared/edge-handler.mjs"; export const port = parseInt(process.env.PORT || "3000", 10); export const hostname = process.env.HOST || "0.0.0.0"; -export const { handler } = await reactServer({ +export const { handler, config } = await reactServer({ origin: process.env.ORIGIN || `http://${hostname}:${port}`, outDir: ".", }); export { createContext }; -export const handleRequest = createRequestHandler(handler, createContext); +export const handleRequest = createRequestHandler( + handler, + createContext, + config +); diff --git a/packages/react-server/adapters/docker/server/entry.edge.mjs b/packages/react-server/adapters/docker/server/entry.edge.mjs index 87a28d32..4c8f6b78 100644 --- a/packages/react-server/adapters/docker/server/entry.edge.mjs +++ b/packages/react-server/adapters/docker/server/entry.edge.mjs @@ -5,10 +5,14 @@ import { createRequestHandler } from "../../shared/edge-handler.mjs"; export const port = parseInt(process.env.PORT || "3000", 10); export const hostname = process.env.HOST || "0.0.0.0"; -export const { handler } = await reactServer({ +export const { handler, config } = await reactServer({ origin: process.env.ORIGIN || `http://${hostname}:${port}`, outDir: ".", }); export { createContext }; -export const handleRequest = createRequestHandler(handler, createContext); +export const handleRequest = createRequestHandler( + handler, + createContext, + config +); diff --git a/packages/react-server/adapters/shared/edge-body-caps.mjs b/packages/react-server/adapters/shared/edge-body-caps.mjs new file mode 100644 index 00000000..86afa936 --- /dev/null +++ b/packages/react-server/adapters/shared/edge-body-caps.mjs @@ -0,0 +1,279 @@ +/** + * Edge / serverless port of the HTTP-layer body and multipart caps. + * + * Mirrors the cap pipeline from `lib/http/middleware.mjs` (the Node + * `createMiddleware` path) — same config shape, same per-cap + * semantics, same 413 / 400 mapping — so behaviour stays symmetric + * across adapter targets. The runtime support matrix: + * + * - **Node-hosted edge entries** (test runners, custom servers + * hosting the edge bundle through `node:http`): full fidelity. + * Both caps apply pre-parse via the bundled `multipart-cap.mjs` + * and a Web-Streams TransformStream wrap of `request.body`. + * - **workerd / Vercel Functions / Bun with `nodejs_compat`**: + * full fidelity. busboy and `Readable.fromWeb` run on the + * compat layer. + * - **Native-edge runtimes without Node compat** (e.g. Deno + * edge): the multipart per-part cap is silently downgraded — + * `Readable.fromWeb` is unavailable, busboy can't load, and + * the runtime falls back to the platform `Request.formData()` + * parser (no per-part cap). The body cap still applies via + * Web Streams. Operators targeting those runtimes should + * terminate per-part limits at their CDN / proxy edge. + * + * Why this lives in the shared adapter layer rather than next to + * the Node middleware: the cap is conceptually an HTTP-server + * policy, not a render-pipeline one, so it belongs at the request + * intake. Putting it here means every edge adapter (Cloudflare, + * Vercel, Netlify, Bun, Deno) that funnels through + * `createEdgeHandler` / `createRequestHandler` picks up the + * enforcement automatically — no per-adapter wiring. + * + * @module + */ + +import { CONFIG_ROOT } from "../../server/symbols.mjs"; + +/** + * Apply runtime-configured body / multipart caps to an incoming + * Web Request. Returns either the (possibly-substituted) request + * for downstream processing, or an early response that the caller + * should short-circuit with. + * + * The cap is a no-op when: + * - the request isn't body-bearing (GET / HEAD / OPTIONS), OR + * - no caps are configured (zero overhead — no body inspection, + * no dynamic imports). + * + * @param {Request} request + * @param {object} config - The runtime config object returned by + * `reactServer({...})`. Passed explicitly rather than pulled from + * `getRuntime(CONFIG_CONTEXT)` because that lookup races with + * init$ — see the comment at the `resolve({ handler, config })` + * call site in `lib/start/edge.mjs`. + * @returns {Promise<{ request: Request } | { response: Response }>} + */ +export async function applyEdgeBodyCaps(request, config) { + const server = config?.[CONFIG_ROOT]?.server ?? {}; + const maxBodyBytes = + typeof server.maxBodyBytes === "number" && server.maxBodyBytes > 0 + ? server.maxBodyBytes + : 0; + const multipart = server.multipart ?? null; + + if (!hasBodyBearingMethod(request.method)) return { request }; + if (maxBodyBytes === 0 && !hasMultipartLimits(multipart)) { + return { request }; + } + + // ── Layer 1: cheap declared-length check ── + // Honest clients send `Content-Length` on non-chunked POSTs. Catch + // the obvious "uploading 5 GB" case from the headers without ever + // reading wire bytes or doing dynamic imports. + if (maxBodyBytes > 0) { + const declared = parseContentLength(request.headers.get("content-length")); + if (declared > maxBodyBytes) { + return { response: payloadTooLarge() }; + } + } + + // ── Layer 2: multipart per-part caps ── + // Parse via busboy when caps are configured AND the request is + // multipart. On success we hand back a Request whose body is the + // parsed FormData; downstream `request.formData()` re-parses the + // re-serialised bytes (functionally identical to the platform + // parser's output, by A/B equivalence test). On overflow we + // return 413 directly. + if ( + hasMultipartLimits(multipart) && + isMultipartContentType(request.headers.get("content-type")) + ) { + try { + const { parseMultipartWithCapFromWebRequest } = + await import("../../lib/http/multipart-cap.mjs"); + const formData = await parseMultipartWithCapFromWebRequest( + request, + multipart + ); + // Drop content-type and content-length so the Request + // constructor sets fresh multipart headers matching the new + // boundary (FormData serialises with its own boundary). Same + // header-strip the Node middleware does. + const headers = new Headers(request.headers); + headers.delete("content-type"); + headers.delete("content-length"); + return { + request: new Request(request.url, { + method: request.method, + headers, + body: formData, + }), + }; + } catch (e) { + if (e?.code === "MULTIPART_LIMIT_EXCEEDED") { + return { response: payloadTooLarge() }; + } + // Native-edge without Node compat: `node:stream` / + // `Readable.fromWeb` / busboy can't load. The cap silently + // downgrades — we still apply the body cap below, and the + // platform parser handles the multipart shape downstream. + // We deliberately don't 400 here: that would block legitimate + // requests just because the runtime can't host the per-part + // cap. + if (isNodeCompatMissingError(e)) { + // fall through to body cap + } else { + // Genuinely malformed multipart (bad boundary, truncated, + // etc.). 400 keeps it distinct from "too big" (413) and + // from "server error" (500). + return { response: badRequest() }; + } + } + } + + // ── Layer 3: body cap, enforced pre-read ── + // + // Unlike the Node `createMiddleware` path, the edge handler chain + // catches user-code errors (including body-stream errors) inside + // the SSR handler and renders a 200 error page rather than letting + // them propagate to the outer adapter catch. A streaming + // TransformStream wrap that errors on overflow would therefore + // surface as a rendered error page, not the intended 413. + // + // Pre-reading the body up to `maxBodyBytes + 1` bytes lets us + // return a 413 Response synchronously from the cap layer, before + // any handler observes the request. The cap value IS the upper + // bound on memory consumption — by definition acceptable, since + // operators choose it deliberately. On overflow we cancel the + // body reader so the underlying connection / stream is released + // without draining attacker bytes past the cap. + if (maxBodyBytes > 0 && request.body) { + const result = await preReadWithCap(request.body, maxBodyBytes); + if (result.overflow) return { response: payloadTooLarge() }; + return { + request: new Request(request.url, { + method: request.method, + headers: request.headers, + // FormData / Uint8Array bodies don't need `duplex` since + // they're buffered. The Request constructor accepts them + // directly. + body: result.bytes, + }), + }; + } + + return { request }; +} + +async function preReadWithCap(body, maxBytes) { + const reader = body.getReader(); + const chunks = []; + let total = 0; + try { + // Loop reads chunks until either the body ends or we exceed + // the cap. The single `if (total > maxBytes)` after pushing the + // chunk is what bounds memory: we never accumulate more than + // `maxBytes + (one chunk - 1)` bytes before deciding. + while (true) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + // Release the upstream — on Node-hosted edge entries this + // cancels the underlying `IncomingMessage` socket; on + // workerd / Bun / Deno it cancels the platform stream. + // Either way no further attacker bytes are read. + try { + await reader.cancel(); + } catch { + // ignore — cancel can race with end-of-stream + } + return { overflow: true }; + } + chunks.push(value); + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } + // Concatenate into a single Uint8Array. Single chunk fast path + // avoids the allocation for the common small-body case. + if (chunks.length === 0) return { overflow: false, bytes: new Uint8Array(0) }; + if (chunks.length === 1) return { overflow: false, bytes: chunks[0] }; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + return { overflow: false, bytes: out }; +} + +function hasBodyBearingMethod(method) { + return ( + method === "POST" || + method === "PUT" || + method === "PATCH" || + method === "DELETE" + ); +} + +function hasMultipartLimits(multipart) { + if (!multipart || typeof multipart !== "object") return false; + return ( + isPositiveNumber(multipart.maxFileSize) || + isPositiveNumber(multipart.maxFieldSize) || + isPositiveNumber(multipart.maxFiles) || + isPositiveNumber(multipart.maxFields) || + isPositiveNumber(multipart.maxParts) || + isPositiveNumber(multipart.maxFieldNameSize) + ); +} + +function isPositiveNumber(v) { + return typeof v === "number" && Number.isFinite(v) && v > 0; +} + +function isMultipartContentType(ct) { + return typeof ct === "string" && /^\s*multipart\/form-data\b/i.test(ct); +} + +function parseContentLength(s) { + if (!s) return -1; + const n = parseInt(s, 10); + return Number.isFinite(n) ? n : -1; +} + +function isNodeCompatMissingError(e) { + if (!e) return false; + const msg = String(e?.message ?? ""); + // Either the dynamic import resolved but `node:stream` / + // `Readable.fromWeb` isn't available, or busboy hit a Node-only + // global (`Buffer`, `setImmediate`, etc.). The exact error text + // varies by runtime; this covers the cases seen on Deno edge and + // workerd without `nodejs_compat`. + return ( + e.code === "ERR_MODULE_NOT_FOUND" || + /Cannot find (module|package)/i.test(msg) || + /node:stream/.test(msg) || + /Readable\.fromWeb/.test(msg) || + /Buffer is not defined/i.test(msg) + ); +} + +function payloadTooLarge() { + return new Response("Payload Too Large", { + status: 413, + headers: { "content-type": "text/plain", connection: "close" }, + }); +} + +function badRequest() { + return new Response("Bad Request", { + status: 400, + headers: { "content-type": "text/plain", connection: "close" }, + }); +} diff --git a/packages/react-server/adapters/shared/edge-handler.mjs b/packages/react-server/adapters/shared/edge-handler.mjs index 722ab958..20ae8979 100644 --- a/packages/react-server/adapters/shared/edge-handler.mjs +++ b/packages/react-server/adapters/shared/edge-handler.mjs @@ -1,6 +1,8 @@ import { reactServer } from "@lazarv/react-server/edge"; import { createContext } from "@lazarv/react-server/http"; +import { applyEdgeBodyCaps } from "./edge-body-caps.mjs"; + /** * Finalize a response by applying set-cookie headers from the HTTP context. * Returns a 404 response if the original response is null/undefined. @@ -55,9 +57,21 @@ export function createEdgeHandler({ serverPromise = reactServer({ origin, outDir }); } - const { handler } = await serverPromise; + const { handler, config } = await serverPromise; + + // Apply HTTP-layer body / multipart caps before user code + // observes the request. Mirrors the same pipeline from the + // Node createMiddleware path so the cap is symmetric across + // adapter targets. See edge-body-caps.mjs for the runtime + // support matrix. Config is passed explicitly rather than + // pulled from AsyncLocalStorage to avoid the init$ timing + // race (see comment at the `resolve({ handler, config })` + // call site in `lib/start/edge.mjs`). + const capResult = await applyEdgeBodyCaps(request, config); + if ("response" in capResult) return capResult.response; + const cappedRequest = capResult.request; - const httpContext = createContext(request, { + const httpContext = createContext(cappedRequest, { origin, runtime, ...(resolvePlatformExtras @@ -81,8 +95,16 @@ export function createEdgeHandler({ * Create a request handler from an already-initialized handler and createContext. * Used by Bun/Deno runtime entries where the server is eagerly initialized * via top-level await. + * + * The third positional `config` argument is the same object returned + * from `reactServer({...})`; it's forwarded to `applyEdgeBodyCaps` + * so the HTTP-layer caps (`server.maxBodyBytes`, `server.multipart.*`) + * apply on the Bun / Deno / Docker top-level-await entries too. + * Existing callers that pass only `(handler, createContext)` still + * work — caps just become a no-op for that adapter until the entry + * is updated to forward `config`. */ -export function createRequestHandler(handlerFn, createContextFn) { +export function createRequestHandler(handlerFn, createContextFn, config) { let origin; return async (request, { runtime, platformExtras } = {}) => { @@ -90,7 +112,14 @@ export function createRequestHandler(handlerFn, createContextFn) { const url = new URL(request.url); origin = origin || process.env.ORIGIN || `${url.protocol}//${url.host}`; - const httpContext = createContextFn(request, { + // See note in createEdgeHandler — applied here too so Bun / + // Deno top-level-await entries enforce the same caps as the + // lazy-init adapters. + const capResult = await applyEdgeBodyCaps(request, config); + if ("response" in capResult) return capResult.response; + const cappedRequest = capResult.request; + + const httpContext = createContextFn(cappedRequest, { origin, runtime, ...(platformExtras ? { platformExtras } : {}), diff --git a/packages/react-server/lib/http/multipart-cap.mjs b/packages/react-server/lib/http/multipart-cap.mjs index 99eb5c48..bd07df5c 100644 --- a/packages/react-server/lib/http/multipart-cap.mjs +++ b/packages/react-server/lib/http/multipart-cap.mjs @@ -229,6 +229,64 @@ export function parseMultipartWithCap(req, limits) { }); } +/** + * Edge / serverless variant: parse the body of a WHATWG `Request` + * with the same per-part caps as the Node `IncomingMessage` path. + * + * Bridges the Web Streams body to Node Readable via `Readable.fromWeb` + * (works on every runtime that exposes `node:stream` — Node 18+, + * workerd with `nodejs_compat`, Bun, Deno's Node-compat layer) and + * synthesises a minimal `req`-shaped object so `parseMultipartWithCap` + * can reuse its busboy pipeline verbatim. This keeps the Node and + * edge adapters honouring the exact same cap semantics rather than + * maintaining two parsers that can drift. + * + * On native-edge runtimes without Node compat the underlying + * `node:stream` / `Buffer` symbols are missing; busboy throws at + * the first use and the caller (edge-body-caps.mjs) falls through + * to the platform `Request.formData()` parser. + * + * IMPORTANT: the synthesised stream is destroyed in `finally` so + * the underlying Web stream is cancelled even on rejection. Without + * this, a 413 path would leak the still-locked request body. + * + * @param {Request} request + * @param {{ + * maxFileSize?: number, + * maxFieldSize?: number, + * maxFiles?: number, + * maxFields?: number, + * maxParts?: number, + * maxFieldNameSize?: number, + * }} limits + * @returns {Promise} + */ +export async function parseMultipartWithCapFromWebRequest(request, limits) { + if (!request.body) return new FormData(); + const { Readable } = await import("node:stream"); + const stream = Readable.fromWeb(request.body); + // busboy reads `req.headers` for the boundary parameter; expose + // the WHATWG headers as a plain lowercase-keyed object. + stream.headers = webHeadersToObject(request.headers); + try { + return await parseMultipartWithCap(stream, limits); + } finally { + try { + stream.destroy(); + } catch { + // ignore — already destroyed + } + } +} + +function webHeadersToObject(headers) { + const obj = {}; + for (const [k, v] of headers) { + obj[k.toLowerCase()] = v; + } + return obj; +} + /** * Drain whatever bytes remain on the source request, discarding * them — used by the middleware after a `MultipartCapError` so diff --git a/packages/react-server/lib/start/edge.mjs b/packages/react-server/lib/start/edge.mjs index 9c415cf0..50cf028e 100644 --- a/packages/react-server/lib/start/edge.mjs +++ b/packages/react-server/lib/start/edge.mjs @@ -82,7 +82,17 @@ export function reactServer(root, options = {}, initialConfig = {}) { : [...initialHandlers, ...(configRoot.handlers ?? [])] ); - resolve({ handler }); + // Expose `config` alongside `handler` so adapter layers + // (notably `createEdgeHandler` in + // `adapters/shared/edge-handler.mjs`) can read runtime + // settings without depending on AsyncLocalStorage timing. + // The `getRuntime(CONFIG_CONTEXT)` lookup races with init$: + // `resolve()` fires from inside the init$ callback, so the + // global default-store assignment that init$ does after the + // callback returns hasn't happened yet by the time consumers + // resume — they'd see `null`. Returning the config explicitly + // sidesteps the race. + resolve({ handler, config }); }); } catch (e) { reject(e); diff --git a/packages/react-server/server/action-state.mjs b/packages/react-server/server/action-state.mjs index 0a115def..e7666c3c 100644 --- a/packages/react-server/server/action-state.mjs +++ b/packages/react-server/server/action-state.mjs @@ -6,7 +6,16 @@ import { ACTION_CONTEXT, SERVER_FUNCTION_NOT_FOUND } from "./symbols.mjs"; export class ServerFunctionNotFoundError extends Error { constructor(message) { super(message); - this.name = SERVER_FUNCTION_NOT_FOUND; + // `name` is intentionally a plain string: Node 20's `util.inspect` + // assumes `err.name` is a string and crashes the whole process with + // `TypeError: Cannot convert a Symbol value to a string` when any + // `console.warn(err)` / `console.error(err)` formats an error whose + // `name` is a `Symbol`. The discriminator that callers actually use + // for cross-realm identity moved to `code` below; this constructor + // preserves the runtime-wide invariant that an error's `name` is a + // human-readable class string. + this.name = "ServerFunctionNotFoundError"; + this.code = SERVER_FUNCTION_NOT_FOUND; this.message = message ?? "Server Function Not Found"; this.stack = new Error().stack; } @@ -29,7 +38,7 @@ export function useActionState(action) { const isMatch = actionId === action.$$id || (action.$$originalId != null && actionId === action.$$originalId); - if (!isMatch && error?.name !== SERVER_FUNCTION_NOT_FOUND) { + if (!isMatch && error?.code !== SERVER_FUNCTION_NOT_FOUND) { return { formData: null, data: null, diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index fa80a2e5..023f0f0a 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -778,7 +778,7 @@ export async function render(Component, props = {}, options = {}) { } })(); - if (error?.name === SERVER_FUNCTION_NOT_FOUND) { + if (error?.code === SERVER_FUNCTION_NOT_FOUND) { const e = new ServerFunctionNotFoundError(); e.digest = e.message; throw e; diff --git a/test/__test__/http-body-cap.spec.mjs b/test/__test__/http-body-cap.spec.mjs index 27808255..82978def 100644 --- a/test/__test__/http-body-cap.spec.mjs +++ b/test/__test__/http-body-cap.spec.mjs @@ -18,6 +18,11 @@ import { describe, expect, test } from "vitest"; * `received:` text Response. That puts a real body-reading * consumer in front of the wrapper without going through the * framework's POST → remote-props decode path. + * + * Runs on every adapter target — both the Node createMiddleware + * path and the edge / serverless path apply the same cap, the + * latter via the Web-Streams TransformStream wrap in + * `adapters/shared/edge-body-caps.mjs`. */ const FIXTURE = "fixtures/body-cap.jsx"; diff --git a/test/__test__/http-multipart-cap.spec.mjs b/test/__test__/http-multipart-cap.spec.mjs index 4c6b3d7a..b62488e4 100644 --- a/test/__test__/http-multipart-cap.spec.mjs +++ b/test/__test__/http-multipart-cap.spec.mjs @@ -3,7 +3,12 @@ import { describe, expect, test } from "vitest"; /** * Integration tests for the streaming multipart cap - * (`server.multipart.*`). + * (`server.multipart.*`). The cap lives in `lib/http/multipart-cap.mjs` + * and is wired into both adapter targets: the Node `createMiddleware` + * path consumes it directly with the raw `IncomingMessage`, and the + * edge / serverless path adapts the Web Request body via + * `Readable.fromWeb` in `adapters/shared/edge-body-caps.mjs`. Both + * paths share the same busboy core so cap semantics stay symmetric. * * The cap defends against attacks that `server.maxBodyBytes` * cannot bound: