diff --git a/docs/src/pages/en/(pages)/features/micro-frontends.mdx b/docs/src/pages/en/(pages)/features/micro-frontends.mdx index fd38f9b0..5d215f18 100644 --- a/docs/src/pages/en/(pages)/features/micro-frontends.mdx +++ b/docs/src/pages/en/(pages)/features/micro-frontends.mdx @@ -255,4 +255,25 @@ To run the example, clone the `@lazarv/react-server` repository and run the foll ```sh pnpm install pnpm --filter ./examples/remote dev -``` \ No newline at end of file +``` + + +## Disabling Remote Components rendering + + +Applications that don't host Remote Components can force-disable the entire rendering path by setting `remoteComponents: false` in the runtime config. The runtime then ignores the `@__react_server_remote__` URL marker, never enters the Remote Components code path, and never parses request bodies as Remote Components payloads. + +```js +// react-server.config.mjs +export default { + remoteComponents: false, +}; +``` + +Two effects worth understanding: + +The remote-rendering routing is removed. URLs carrying the `@__react_server_remote__` marker are no longer treated as Remote Components fetches; they fall through to normal page rendering as if the marker weren't there. + +Temporary references — the runtime mechanism that lets non-serializable client values (callbacks, DOM refs, opaque thenables) round-trip through `$T` tags on the wire — are gated by the same flag. Their only legitimate use is round-tripping values back to the same client during a Remote Components render, so when `remoteComponents: false` is set, the temporary-reference set is not created and any `$T` tag in an incoming request body is rejected as malformed. This eliminates a small but real attack surface: a fabricated body otherwise allocates proxy objects and runs opaque lookups against the in-memory reference map. + +This pairs with `serverFunctions: false` (see [Disabling Server Functions entirely](../guide/server-functions#disabling-server-functions)) for apps that want to ship a strictly read-only surface — neither Server Functions nor Remote Components remain reachable, and any `POST`/`PUT`/`PATCH`/`DELETE` traffic flows through to normal rendering without parsing the request body. \ No newline at end of file diff --git a/docs/src/pages/en/(pages)/guide/server-functions.mdx b/docs/src/pages/en/(pages)/guide/server-functions.mdx index 190bf6bd..576c4d48 100644 --- a/docs/src/pages/en/(pages)/guide/server-functions.mdx +++ b/docs/src/pages/en/(pages)/guide/server-functions.mdx @@ -1,29 +1,29 @@ --- -title: Server functions +title: Server Functions order: 4 --- import Link from "../../../../components/Link.jsx"; -# Server functions +# Server Functions -Server functions are async functions that can be called from the client-side. You don't have to implement API endpoints for these functions to be callable as the client can call these functions like a direct reference to the function itself. Server functions are usable on forms, buttons, submit inputs, and as props to client components. React and the runtime will manage calling these functions on the server-side. +Server Functions are async functions that can be called from the client-side. You don't have to implement API endpoints for these functions to be callable as the client can call these functions like a direct reference to the function itself. Server Functions are usable on forms, buttons, submit inputs, and as props to client components. React and the runtime will manage calling these functions on the server-side. -You can expose any `"use server";` marked function as a server function. Server functions can be called from the client using the `action` prop on a `
` element, `formAction` on a ` @@ -53,33 +53,33 @@ export default function App() { } ``` -> Server functions are able to access all variables in scope, including references to props and any variables available in the scope of the server function from the last render of the component as server functions are mapped on each render of the server component. +> Server Functions are able to access all variables in scope, including references to props and any variables available in the scope of the Server Function from the last render of the component as Server Functions are mapped on each render of the server component. -## Server function modules +## Server Function modules -If you want to keep your server functions in separate modules, you can do so by using the `"use server";` pragma at the top of your module. All exported functions from a server function module will be usable as server functions. +If you want to keep your Server Functions in separate modules, you can do so by using the `"use server";` pragma at the top of your module. All exported functions from a Server Function module will be usable as Server Functions. ```js "use server"; export async function action() { - console.log("Server function called!"); + console.log("Server Function called!"); } ``` -## Server function parameters +## Server Function parameters -You will get all form data as an object as the first parameter to your server function. +You will get all form data as an object as the first parameter to your Server Function. ```jsx export default function App() { async function action(formData) { "use server"; - console.log(`Server function called by ${formData.get("name")}!`); + console.log(`Server Function called by ${formData.get("name")}!`); } return ( @@ -101,7 +101,7 @@ import { useActionState } from "@lazarv/react-server/router"; export default function App() { async function action(formData) { "use server"; - console.log(`Server function called by ${formData.get("name")}!`); + console.log(`Server Function called by ${formData.get("name")}!`); } const { error } = useActionState(action); @@ -116,18 +116,18 @@ export default function App() { } ``` -To access the action state, you can use the `useActionState` hook. The `useActionState` hook takes the server function as the first parameter and returns an object with the following properties: +To access the action state, you can use the `useActionState` hook. The `useActionState` hook takes the Server Function as the first parameter and returns an object with the following properties: - `formData`: the form data object -- `data`: the data object returned by the server function +- `data`: the data object returned by the Server Function - `error`: the error object if the action failed - `actionId`: the action ID of the current action -## Server functions with client components +## Server Functions with client components -You can also pass server function references to client components as props and call them from the client component as any other async function. +You can also pass Server Function references to client components as props and call them from the client component as any other async function. ```jsx "use client"; @@ -147,7 +147,7 @@ import MyClientComponent from "./MyClientComponent"; export default function App() { async function action({ name }) { "use server"; - console.log(`Server function called by ${name}!`); + console.log(`Server Function called by ${name}!`); } return ( @@ -159,10 +159,10 @@ export default function App() { ``` -## Server function response to client component calls +## Server Function response to client component calls -Server functions called from client components can return data to the client component directly. This can be useful if you want to display a message to the user after the server function has been completed or if you want to display an error message if the server function failed. +Server Functions called from client components can return data to the client component directly. This can be useful if you want to display a message to the user after the Server Function has been completed or if you want to display an error message if the Server Function failed. ```jsx "use client"; @@ -190,7 +190,7 @@ import MyClientComponent from "./MyClientComponent"; export default function App() { async function action({ name }) { "use server"; - console.log(`Server function called by ${name}!`); + console.log(`Server Function called by ${name}!`); return { message: `Hello ${name}!` }; } @@ -203,10 +203,10 @@ export default function App() { ``` -## Inline server functions with inline client components +## Inline Server Functions with inline client components -You can combine inline `"use server"` functions and inline `"use client"` components in the same file. This is useful when a server function and the client UI that consumes it are closely related. +You can combine inline `"use server"` functions and inline `"use client"` components in the same file. This is useful when a Server Function and the client UI that consumes it are closely related. ```jsx import { useState, useTransition } from "react"; @@ -245,13 +245,13 @@ export default function App() { } ``` -Both the server function and the client component are extracted into separate modules automatically. The server function can capture variables from the module scope, and the client component can call the server function directly. +Both the Server Function and the client component are extracted into separate modules automatically. The Server Function can capture variables from the module scope, and the client component can call the Server Function directly. -## Returning client components from server functions +## Returning client components from Server Functions -A server function can define an inline `"use client"` component and return it as rendered JSX. The client component will be serialized through the RSC protocol and hydrated on the client, making it fully interactive. +A Server Function can define an inline `"use client"` component and return it as rendered JSX. The client component will be serialized through the RSC protocol and hydrated on the client, making it fully interactive. ```jsx import { useState, useTransition } from "react"; @@ -296,13 +296,13 @@ export default function App() { } ``` -The `createCounter` server function defines a `Counter` client component, renders it with the given props, and returns the element. The framework extracts the nested directives into separate modules through a chain of virtual modules, so everything works from a single file. +The `createCounter` Server Function defines a `Counter` client component, renders it with the given props, and returns the element. The framework extracts the nested directives into separate modules through a chain of virtual modules, so everything works from a single file. -## Inline server functions in "use client" files +## Inline Server Functions in "use client" files -You can define inline `"use server"` functions inside a file that already has a top-level `"use client"` directive. The server functions are automatically extracted from the client module, so you can keep related server logic and client UI together in a single file. +You can define inline `"use server"` functions inside a file that already has a top-level `"use client"` directive. The Server Functions are automatically extracted from the client module, so you can keep related server logic and client UI together in a single file. ```jsx "use client"; @@ -340,19 +340,19 @@ export default function TodoApp() { } ``` -The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the server function in a standalone `"use server"` file — the runtime handles the extraction automatically. +The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the Server Function in a standalone `"use server"` file — the runtime handles the extraction automatically. ## Security -Server function calls travel a round-trip across the network: the runtime emits a reference to the function, the client invokes it, and the runtime resolves and runs the function on the server. Two things need to be tamper-evident across that round-trip — the *identity* of the action being called, and any *captured values* that travel with it. +Server Function calls travel a round-trip across the network: the runtime emits a reference to the function, the client invokes it, and the runtime resolves and runs the function on the server. Two things need to be tamper-evident across that round-trip — the *identity* of the action being called, and any *captured values* that travel with it. ### Action identity and bound captures -Every server function reference is encoded as a single AES-256-GCM token. The token's plaintext is the pair `(actionId, bound)`, where `bound` is the array of values that were captured by `.bind(...)` or by an inline closure at render time, or `null` for unbound actions. The ciphertext is what the client sees on the wire, and what it sends back when the action is invoked. +Every Server Function reference is encoded as a single AES-256-GCM token. The token's plaintext is the pair `(actionId, bound)`, where `bound` is the array of values that were captured by `.bind(...)` or by an inline closure at render time, or `null` for unbound actions. The ciphertext is what the client sees on the wire, and what it sends back when the action is invoked. ```jsx function ProfilePage({ userId }) { @@ -369,14 +369,14 @@ function ProfilePage({ userId }) { } ``` -In the example above, `userId` is captured by the inline server function. The runtime emits a token that bundles both the action's identity and the captured `userId` together. The client never sees `userId` in plaintext, never round-trips it as a separate value, and cannot edit it without invalidating the token's authentication tag — which causes the call to be rejected. +In the example above, `userId` is captured by the inline Server Function. The runtime emits a token that bundles both the action's identity and the captured `userId` together. The client never sees `userId` in plaintext, never round-trips it as a separate value, and cannot edit it without invalidating the token's authentication tag — which causes the call to be rejected. -This applies to every form of server function: +This applies to every form of Server Function: - Module-scope `"use server"` functions (no captures → bound is `null`) - Inline closures with render-time captures -- Server-side `.bind(...)` usage to partially apply a server function -- Bound server references passed as arguments to other server functions +- Server-side `.bind(...)` usage to partially apply a Server Function +- Bound server references passed as arguments to other Server Functions ### Configuration @@ -422,10 +422,27 @@ The same rotation applies to both action identity and bound captures — they sh ### Client-side `.bind()` -When a bound server function is exposed to a client component and the client calls `.bind(...)` to add more arguments, those additional arguments are treated as *runtime arguments*, not as new captures. They travel as ordinary call args (alongside whatever the user passes at invocation), and they are not included inside the encrypted token. This is intentional: only the original server-emitted bound is integrity-protected. Client-added arguments are effectively just the arguments the client chooses to send at call time. +When a bound Server Function is exposed to a client component and the client calls `.bind(...)` to add more arguments, those additional arguments are treated as *runtime arguments*, not as new captures. They travel as ordinary call args (alongside whatever the user passes at invocation), and they are not included inside the encrypted token. This is intentional: only the original server-emitted bound is integrity-protected. Client-added arguments are effectively just the arguments the client chooses to send at call time. + + +### Disabling Server Functions entirely + + +If your application has no Server Functions at all, set `serverFunctions: false` in the runtime config. The runtime then refuses to decode any action request: incoming `POST`/`PUT`/`PATCH`/`DELETE` traffic is not parsed for action calls, the manifest is not queried, and the request flows through to normal page rendering as if the action endpoints didn't exist. This eliminates the action-dispatch surface as an attack target and removes a small amount of per-request overhead. + +```js +// react-server.config.mjs +export default { + serverFunctions: false, +}; +``` + +In production builds with no `"use server"` modules and no inline Server Functions, the runtime auto-detects an empty manifest and applies the same gate without explicit configuration. The explicit `false` is for cases where you want the gate even before a build (development), or as a deliberate posture choice regardless of what the build sees. + +Remote Components rendering can be force-disabled in the same defense-in-depth spirit via `remoteComponents: false` — see [Disabling Remote Components rendering](../features/micro-frontends#disabling-remote-components) on the Micro-frontends page. ### Limitations -The token covers the values in the bound array. If a captured value is a `File` or `Blob`, the token covers the slot reference that points to the binary content, but not the binary content itself. Servers that bind server-constructed binary data into a closure should be aware that an attacker controlling the upload could substitute the binary content even with a valid token. In practice, captured `File`/`Blob` values are rare in server function closures. +The token covers the values in the bound array. If a captured value is a `File` or `Blob`, the token covers the slot reference that points to the binary content, but not the binary content itself. Servers that bind server-constructed binary data into a closure should be aware that an attacker controlling the upload could substitute the binary content even with a valid token. In practice, captured `File`/`Blob` values are rare in Server Function closures. diff --git a/docs/src/pages/ja/(pages)/features/micro-frontends.mdx b/docs/src/pages/ja/(pages)/features/micro-frontends.mdx index edf0f6d7..e20fda04 100644 --- a/docs/src/pages/ja/(pages)/features/micro-frontends.mdx +++ b/docs/src/pages/ja/(pages)/features/micro-frontends.mdx @@ -201,4 +201,25 @@ export default { ```sh pnpm install pnpm --filter ./examples/remote dev -``` \ No newline at end of file +``` + + +## リモートコンポーネントのレンダリングを無効化する + + +リモートコンポーネントをホストしないアプリケーションは、ランタイム設定に `remoteComponents: false` を指定することでレンダリングパス全体を強制的に無効化できます。これによりランタイムは `@__react_server_remote__` の URL マーカーを無視し、リモートコンポーネントのコードパスには入らず、リクエスト本体をリモートコンポーネントのペイロードとしてパースしません。 + +```js +// react-server.config.mjs +export default { + remoteComponents: false, +}; +``` + +理解しておくべき効果は二つあります。 + +リモートレンダリングのルーティングが除去されます。`@__react_server_remote__` マーカーを含む URL はリモートコンポーネントへのフェッチとして扱われなくなり、マーカーがないかのように通常のページレンダリングへフォールスルーします。 + +ワイヤ上で `$T` タグを介して非シリアライザブルなクライアント値 (コールバック、DOM 参照、不透明な thenable) をラウンドトリップさせるランタイム機構である一時参照も、同じフラグでゲートされます。一時参照の正当な用途はリモートコンポーネントのレンダリング中に同じクライアントへ値を返送することのみであるため、`remoteComponents: false` が設定されている場合は一時参照セットを作成せず、受信リクエスト本体内のいかなる `$T` タグも不正として拒否します。これにより、攻撃者が偽造したリクエスト本体によってプロキシオブジェクトを確保させたり、メモリ上の参照マップに対する不透明な検索を実行させたりする、わずかではありますが実在する攻撃面を排除できます。 + +`serverFunctions: false` ([サーバ関数を完全に無効化する](../guide/server-functions#disabling-server-functions) を参照) と組み合わせることで、厳密に読み取り専用のサーフェスを公開するアプリケーションを構築できます。サーバ関数もリモートコンポーネントもアクセスできず、`POST` / `PUT` / `PATCH` / `DELETE` トラフィックはリクエスト本体をパースすることなく通常のレンダリングへフォールスルーします。 \ No newline at end of file diff --git a/docs/src/pages/ja/(pages)/guide/server-functions.mdx b/docs/src/pages/ja/(pages)/guide/server-functions.mdx index 1be43503..d49f59d8 100644 --- a/docs/src/pages/ja/(pages)/guide/server-functions.mdx +++ b/docs/src/pages/ja/(pages)/guide/server-functions.mdx @@ -284,6 +284,23 @@ export default { バインド済みのサーバ関数がクライアントコンポーネントに渡され、クライアントがさらに `.bind(...)` を呼んで引数を追加した場合、これらの追加引数は新しいキャプチャではなく **ランタイム引数** として扱われます。これらは通常の呼び出し引数 (ユーザが呼び出し時に渡すものと同様) として送られ、暗号化されたトークンには含まれません。これは意図的な仕様です — サーバから発行されたバインドのみが整合性で保護され、クライアントが追加した引数は実質的にクライアントが呼び出し時に送ることを選んだ値に過ぎません。 + +### サーバ関数を完全に無効化する + + +アプリケーションがサーバ関数を一切持たない場合、ランタイム設定で `serverFunctions: false` を指定してください。これによりランタイムはアクションリクエストの復号を一切行わなくなります。受信した `POST` / `PUT` / `PATCH` / `DELETE` トラフィックはアクション呼び出しとしてパースされず、マニフェストも参照されず、リクエストは通常のページレンダリングへとフォールスルーします。アクションディスパッチを攻撃対象から取り除き、リクエストごとのわずかなオーバーヘッドも削減できます。 + +```js +// react-server.config.mjs +export default { + serverFunctions: false, +}; +``` + +`"use server"` モジュールやインラインサーバ関数を一切含まない本番ビルドでは、ランタイムが空のマニフェストを自動検出し、明示的な設定なしで同じゲートを適用します。明示的な `false` は、ビルド前 (開発時) からゲートを効かせたい場合や、ビルド結果に依存しない明確な姿勢として無効化したい場合に使用します。 + +リモートコンポーネントのレンダリングも同じ多層防御の方針で `remoteComponents: false` により強制的に無効化できます。マイクロフロントエンドのページの [リモートコンポーネントのレンダリングを無効化する](../features/micro-frontends#disabling-remote-components) を参照してください。 + ### 制限事項 diff --git a/packages/react-server/config/schema.d.ts b/packages/react-server/config/schema.d.ts index ec3eeb3c..96e8e42d 100644 --- a/packages/react-server/config/schema.d.ts +++ b/packages/react-server/config/schema.d.ts @@ -796,25 +796,25 @@ export interface CacheConfig { providers?: Record | unknown[]; } -// ───── Server functions config ───── +// ───── Server Functions config ───── /** * Resource ceilings applied when decoding a client → server RSC reply - * (server function arguments). + * (Server Function arguments). * - * These limits are enforced inside the reply decoder before any server - * function runs. They cap the cost of payload deserialization and protect - * the server from denial-of-service vectors that exploit the rich - * RSC reply wire format (deep nesting, huge BigInts, oversized streams, - * unbounded bound-argument lists, etc.). + * These limits are enforced inside the reply decoder before any + * Server Function runs. They cap the cost of payload deserialization + * and protect the server from denial-of-service vectors that exploit + * the rich RSC reply wire format (deep nesting, huge BigInts, oversized + * streams, unbounded bound-argument lists, etc.). * * Each limit is independent. A zero or negative value is treated as the * default. Setting a value larger than the default loosens the ceiling; * setting a smaller value tightens it. * * When a request exceeds any limit, the decoder throws a - * `DecodeLimitError` and the request is rejected before the server - * function is invoked. + * `DecodeLimitError` and the request is rejected before the + * Server Function is invoked. */ export interface ServerFunctionDecodeLimits { /** @@ -837,7 +837,7 @@ export interface ServerFunctionDecodeLimits { maxBytes?: number; /** - * Maximum number of bound arguments on a server reference. + * Maximum number of bound arguments on a Server Function reference. * Matches React's upstream default. * @default 256 */ @@ -866,7 +866,7 @@ export interface ServerFunctionDecodeLimits { export interface ServerFunctionsConfig { /** - * Secret key for signing server function calls. + * Secret key for signing Server Function calls. * @example `secret: "my-secret-key"` */ secret?: string; @@ -890,7 +890,7 @@ export interface ServerFunctionsConfig { previousSecretFiles?: string[]; /** - * Resource ceilings for decoding server function payloads. + * Resource ceilings for decoding Server Function payloads. * * Caps the maximum work the server will do per inbound RSC reply. * Defaults match the @lazarv/rsc decoder's built-in safe ceilings, @@ -1444,14 +1444,26 @@ export interface ReactServerConfig { cache?: CacheConfig; /** - * Server functions (RPC) configuration. - * @example `serverFunctions: { secret: "my-secret-key" }` + * Server Functions (RPC) configuration. Set to `false` to force-disable + * all Server Function processing — incoming POSTs are not decoded, the + * manifest is not queried, and the runtime falls through to normal page + * rendering. Object form configures crypto material and decode limits. + * @example `serverFunctions: { secret: "my-secret-key" } // or serverFunctions: false` */ - serverFunctions?: ServerFunctionsConfig; + serverFunctions?: ServerFunctionsConfig | false; + + /** + * Force-disable Remote Components rendering. Only `false` is meaningful — + * when set, the runtime ignores the `@__react_server_remote__` URL marker, + * the temporary-reference set is not created, and request bodies for + * Remote Components rendering are not decoded. + * @example `remoteComponents: false` + */ + remoteComponents?: false; /** * OpenTelemetry observability configuration. - * When enabled, the runtime instruments HTTP requests, rendering, server functions, and middleware. + * When enabled, the runtime instruments HTTP requests, rendering, Server Functions, and middleware. * @example `telemetry: { enabled: true, serviceName: "my-app" }` */ telemetry?: TelemetryConfig; diff --git a/packages/react-server/config/schema.mjs b/packages/react-server/config/schema.mjs index 599a6b6a..f9dc9adc 100644 --- a/packages/react-server/config/schema.mjs +++ b/packages/react-server/config/schema.mjs @@ -55,6 +55,8 @@ export const DESCRIPTIONS = { cookies: "Cookie options for the session.", host: 'Host to listen on. Example: "0.0.0.0" or true (all interfaces).', port: "Port to listen on (0–65535).", + remoteComponents: + "Force-disable Remote Components rendering. Only `false` is meaningful — when set, the runtime ignores the `@__react_server_remote__` URL marker and never enters the Remote Components code path. Apps that don't host Remote Components can set this for defense-in-depth.", console: "Disable the dev console overlay.", overlay: "Disable the dev error overlay.", assetsInclude: @@ -244,15 +246,16 @@ export const DESCRIPTIONS = { "cache.providers": "Cache storage providers.", // serverFunctions.* - serverFunctions: "Server functions (RPC) configuration.", - "serverFunctions.secret": "Secret key for signing server function calls.", + serverFunctions: + "Server Functions (RPC) configuration. Set to `false` to force-disable all Server Function processing — incoming POSTs are not decoded, the manifest is not queried, and the runtime falls through to normal page rendering. Object form configures crypto material and decode limits.", + "serverFunctions.secret": "Secret key for signing Server Function calls.", "serverFunctions.secretFile": "Path to a file containing the secret key.", "serverFunctions.previousSecrets": "Previously used secrets for key rotation.", "serverFunctions.previousSecretFiles": "Previously used secret files for key rotation.", "serverFunctions.limits": - "Resource ceilings for decoding server function payloads (per-request DoS protection).", + "Resource ceilings for decoding Server Function payloads (per-request DoS protection).", "serverFunctions.limits.maxRows": "Maximum number of outlined rows per reply. Default: 10000.", "serverFunctions.limits.maxDepth": @@ -288,7 +291,7 @@ export const DESCRIPTIONS = { // telemetry.* telemetry: - "OpenTelemetry observability configuration. When enabled, the runtime instruments HTTP requests, rendering, server functions, and middleware.", + "OpenTelemetry observability configuration. When enabled, the runtime instruments HTTP requests, rendering, Server Functions, and middleware.", "telemetry.enabled": "Enable/disable telemetry. Also enabled by OTEL_EXPORTER_OTLP_ENDPOINT or REACT_SERVER_TELEMETRY env vars.", "telemetry.serviceName": @@ -504,6 +507,7 @@ export function generateJsonSchema() { cookies: prop({ type: "object" }, "cookies"), host: prop({ oneOf: [{ type: "string" }, { const: true }] }, "host"), port: prop({ type: "integer", minimum: 0, maximum: 65535 }, "port"), + remoteComponents: prop({ const: false }, "remoteComponents"), console: prop({ type: "boolean" }, "console"), overlay: prop({ type: "boolean" }, "overlay"), assetsInclude: prop( @@ -1017,6 +1021,12 @@ export function generateJsonSchema() { ), // ── serverFunctions.* ── + // The runtime validator additionally accepts the literal `false` + // to force-disable all server-function processing — see + // config/validate.mjs (`falseOrShape`). The JSON schema below + // describes the canonical object form for IDE autocomplete; the + // boolean shortcut isn't surfaced here because most users opt out + // via JS config rather than JSON. serverFunctions: prop( { type: "object", diff --git a/packages/react-server/config/validate.mjs b/packages/react-server/config/validate.mjs index d1533b07..db4ea25e 100644 --- a/packages/react-server/config/validate.mjs +++ b/packages/react-server/config/validate.mjs @@ -57,6 +57,25 @@ function objectShape(shape) { return fn; } +/** + * Accepts the literal `false` OR an object that satisfies `shape`. + * + * Used for force-disable config keys like `serverFunctions: false` where + * the same key also accepts a richer object form for normal configuration. + * + * Returns a validator that passes for `false` or any object value. + * The `_shape` property is preserved so the engine's nested-shape recursion + * (validateObject) still validates inner keys when the value is an object. + * The recursion guard (`_shape && is.object(value)`) keeps `false` from + * being walked as an object. + */ +function falseOrShape(shape) { + const fn = (v) => v === false || is.object(v); + fn._shape = shape; + fn._allowFalse = true; + return fn; +} + function enumOf(...values) { const fn = (v) => values.includes(v); fn._enum = values; @@ -212,6 +231,13 @@ const REACT_SERVER_SCHEMA = { host: optional(oneOf(is.string, (v) => v === true)), port: optional(is.number), + // Force-disable Remote Components rendering. When `false`, the runtime + // ignores the `@__react_server_remote__` URL marker, never enters the + // Remote Components code path, and never parses request bodies as + // Remote Components payloads. Any other value (or omitted) leaves + // Remote Components rendering enabled. + remoteComponents: optional((v) => v === false), + // ── Dev overlay / console ── console: optional(is.boolean), overlay: optional(is.boolean), @@ -461,8 +487,14 @@ const REACT_SERVER_SCHEMA = { ), // ── serverFunctions.* ── + // Accepts the existing object shape OR the boolean `false` to force-disable + // all Server Functions processing. When `false`, the runtime's action-dispatch + // block never executes, the request body is never parsed for action calls, + // and the manifest is never queried — the request flows through to normal + // page rendering. Useful for apps that have no Server Functions and want + // to eliminate the action-dispatch surface as a precaution. serverFunctions: optional( - objectShape({ + falseOrShape({ secret: optional(is.string), secretFile: optional(is.string), previousSecrets: optional(arrayOf(is.string)), @@ -607,6 +639,7 @@ const EXAMPLES = { cookies: `cookies: { secure: true, sameSite: "lax" }`, host: `host: "0.0.0.0" // or true for all interfaces`, port: `port: 3000`, + remoteComponents: `remoteComponents: false // disable Remote Components rendering`, console: `console: false // disable dev console overlay`, overlay: `overlay: false // disable dev error overlay`, assetsInclude: `assetsInclude: ["**/*.gltf"] // or string | RegExp`, @@ -714,7 +747,7 @@ const EXAMPLES = { cache: `cache: { profiles: { ... }, providers: { ... } }`, "cache.profiles": `cache: { profiles: { default: { ttl: 60 } } }`, "cache.providers": `cache: { providers: { memory: { ... } } }`, - serverFunctions: `serverFunctions: { secret: "my-secret-key" }`, + serverFunctions: `serverFunctions: { secret: "my-secret-key" } // or serverFunctions: false to disable`, "serverFunctions.secret": `serverFunctions: { secret: "my-secret-key" }`, "serverFunctions.secretFile": `serverFunctions: { secretFile: "./secret.pem" }`, "serverFunctions.previousSecrets": `serverFunctions: { previousSecrets: ["old-secret"] }`, diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs index 1a75aa8f..579d67a7 100644 --- a/packages/react-server/lib/dev/ssr-handler.mjs +++ b/packages/react-server/lib/dev/ssr-handler.mjs @@ -196,11 +196,24 @@ export default async function ssrHandler(root) { const isMultipart = !!httpContext.request.headers .get("content-type") ?.includes("multipart/form-data"); + // Mirror the feature gates render-rsc.jsx applies. When + // server functions or remote rendering are force-disabled + // via config, the corresponding request shapes get demoted + // to "regular request" here so we route them through the + // client-root SSR shortcut instead of activating the full + // RSC entry. Defense-in-depth: render-rsc.jsx re-checks the + // same gates, so even if a request slips through here it + // never reaches the action-dispatch code path. + const serverFunctionsEnabled = + configRoot.serverFunctions !== false; + const remoteEnabled = configRoot.remoteComponents !== false; const isActionRequest = - isMutating && (hasActionHeader || isMultipart); - const isRemoteRequest = httpContext.url.pathname.includes( - "@__react_server_remote__" - ); + serverFunctionsEnabled && + isMutating && + (hasActionHeader || isMultipart); + const isRemoteRequest = + remoteEnabled && + httpContext.url.pathname.includes("@__react_server_remote__"); const entryModule = isClientRoot && !isActionRequest && !isRemoteRequest ? clientRootEntryModule diff --git a/packages/react-server/lib/start/ssr-handler.mjs b/packages/react-server/lib/start/ssr-handler.mjs index b66b2038..96410247 100644 --- a/packages/react-server/lib/start/ssr-handler.mjs +++ b/packages/react-server/lib/start/ssr-handler.mjs @@ -506,8 +506,20 @@ export default async function ssrHandler(root, options = {}) { const isMultipart = !!httpContext.request.headers .get("content-type") ?.includes("multipart/form-data"); + // Mirror the feature gates render-rsc.jsx applies. Demote + // gated request shapes to "regular request" here so the + // shortcut-vs-RSC routing avoids the heavier RSC entry. + // The manifest-emptiness half of the gate stays in + // render-rsc.jsx (it has the manifest in scope); this layer + // only honors the explicit `config.serverFunctions: false` + // and `config.remoteComponents: false` overrides. + const serverFunctionsEnabled = + configRoot.serverFunctions !== false; + const remoteEnabled = configRoot.remoteComponents !== false; const isActionRequest = - isMutating && (hasActionHeader || isMultipart); + serverFunctionsEnabled && + isMutating && + (hasActionHeader || isMultipart); // Remote-component fetches (`.remote.x-component`) expect a // flight payload the host's `createFromFetch` can parse. // render-ssr.jsx's remote branch falls into HTML SSR and the @@ -522,7 +534,8 @@ export default async function ssrHandler(root, options = {}) { // the `.remote.x-component` suffix and the `@.` prefix // from `httpContext.url.pathname`, so a `.includes("@__react_server_remote__")` // check would always read `false` for an actual remote request. - const isRemoteRequest = !!renderContext.flags?.isRemote; + const isRemoteRequest = + remoteEnabled && !!renderContext.flags?.isRemote; const useShortcut = isClientRoot && !isActionRequest && !isRemoteRequest; const dispatchRender = useShortcut diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index f05cf20f..ce8f4d18 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -194,9 +194,20 @@ export async function render(Component, props = {}, options = {}) { revalidate$(); const renderContext = getContext(RENDER_CONTEXT); - const remote = renderContext.flags.isRemote; + // Feature gate for remote components. When + // `config.remoteComponents === false` both flavours of + // remote-rendering signal are forced off so the request flows + // through normal rendering: the URL's `@__react_server_remote__` + // marker (which the SSR handler also re-checks) is ignored, and + // any upstream-propagated isRemote flag becomes irrelevant. + // Unlike the server-functions gate, there's no manifest to detect + // emptiness for — opting out is a deliberate user decision, + // configured via `remoteComponents: false`. + const remoteEnabled = config?.remoteComponents !== false; + const remote = remoteEnabled ? renderContext.flags.isRemote : false; const outlet = useOutlet(); - const remoteRSC = outlet.includes("__react_server_remote__"); + const remoteRSC = + remoteEnabled && outlet.includes("__react_server_remote__"); const origin = remoteRSC ? context.url.origin : context.request.headers.get("origin"); @@ -217,10 +228,55 @@ export async function render(Component, props = {}, options = {}) { const serverActionHeader = decodeURIComponent( context.request.headers.get("react-server-action") ?? null ); - if ( + + // Wire-shape detection for server-function call requests. Used + // both by the action-dispatch gate below and by the remote-props + // body-read gate further down: action-shaped requests must never + // be parsed as remote-props, regardless of whether the dispatch + // actually runs. Otherwise, a multipart action POST submitted + // to an app with `serverFunctions: false` would have its body + // pulled through `decodeReply` (which expects JSON), producing + // a `SyntaxError: No number after minus sign in JSON…` from the + // multipart form bytes. + const isActionRequest = "POST,PUT,PATCH,DELETE".includes(context.request.method) && - ((serverActionHeader && serverActionHeader !== "null") || - isFormData) && + ((serverActionHeader && serverActionHeader !== "null") || isFormData); + + // Feature gate for server functions. When disabled, no part of + // the action-dispatch block runs — the request body is never + // parsed, the action token is never decrypted, the manifest is + // never queried. An attacker can still POST at the endpoint + // but the runtime behaves as if it were a static site: the + // request flows through to normal page rendering. + // + // Two ways the gate is "off": + // 1. The user explicitly forced it off via `serverFunctions: + // false` in the runtime config. + // 2. Production build with an empty server-reference manifest + // (no `"use server"` modules and no inline server functions + // survived the build). The manifest is replaced by a + // literal JSON object at build time via + // lib/plugins/server-reference-map.mjs:writeBundle, so + // Object.keys is meaningful. + // + // Dev mode always defaults to `true` because the dev manifest is + // a lazy Proxy that fabricates entries on demand — emptiness is + // not a reliable signal there, and devs are iterating anyway. + const serverFunctionsEnabled = + config?.serverFunctions !== false && + (import.meta.env.DEV || + (() => { + try { + return Object.keys(_serverReferenceMap).length > 0; + } catch { + // Be safe on any unexpected manifest shape. + return true; + } + })()); + + if ( + serverFunctionsEnabled && + isActionRequest && !options.skipFunction ) { let action = async function () { @@ -528,10 +584,48 @@ export async function render(Component, props = {}, options = {}) { throw options.middlewareError; } - const temporaryReferences = createTemporaryReferenceSet(); + // Temporary references are only meaningful for remote-component + // round-trips: a non-serializable client value (callback, DOM ref, + // anything that earns a $T tag on the wire) needs both encoder + // and decoder sides to share the same reference set so the value + // can be passed back to the same client. Page renders and plain + // server-function calls don't depend on them. + // + // When `remoteComponents: false` is set, we don't create the set + // at all. Downstream decoders that receive `null` for + // `temporaryReferences` will reject any incoming `$T` tag (no + // legitimate origin for one to exist), and downstream encoders + // simply won't emit them. This removes a small but real attack + // surface: a fabricated request body containing `$T` references + // would otherwise allocate proxy objects and execute opaque + // lookups against an in-memory map. + const temporaryReferences = remoteEnabled + ? createTemporaryReferenceSet() + : null; context$(RENDER_TEMPORARY_REFERENCES, temporaryReferences); + // Body-as-remote-props decoding is exclusively for remote component + // requests — it parses the request body into the prop tree the + // remote render uses (JSON-encoded reply format). Three things + // to gate on: + // + // 1. `remoteEnabled` — when remote-component rendering is + // force-disabled, this block never fires regardless of + // request shape. + // 2. `!isActionRequest` — action-shaped requests (multipart + // forms or `react-server-action` header) carry payloads + // the action-dispatch path consumes. With the action + // dispatch gated by `serverFunctions: false`, the body + // stream is no longer locked by `context.request.formData()`, + // so without this guard a multipart action POST would be + // pulled into `decodeReply` and `JSON.parse` would throw + // on the multipart bytes. + // 3. `!body.locked` — the existing safety check; if any earlier + // consumer (action-dispatch in the default-enabled path) + // already drained the stream, skip. if ( + remoteEnabled && + !isActionRequest && !options.middlewareError && context.request.body && context.request.body instanceof ReadableStream && @@ -543,7 +637,7 @@ export async function render(Component, props = {}, options = {}) { } body = body || "{}"; } - if (body) { + if (remoteEnabled && body) { const remoteProps = await decodeReply(body, serverReferenceMap, { temporaryReferences, }); diff --git a/test/__test__/config-validate.spec.mjs b/test/__test__/config-validate.spec.mjs index 0912519c..912b6806 100644 --- a/test/__test__/config-validate.spec.mjs +++ b/test/__test__/config-validate.spec.mjs @@ -2204,3 +2204,48 @@ describe("config validation — real-world configs", () => { }); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature gates — serverFunctions: false / remoteComponents: false +// ═══════════════════════════════════════════════════════════════════════════ + +describe("config validation — serverFunctions: false (force-disable)", () => { + it("accepts the literal `false`", () => { + expectValid({ serverFunctions: false }); + }); + + it("still accepts the existing object form", () => { + expectValid({ serverFunctions: { secret: "k" } }); + }); + + it("rejects any other boolean / non-object value", () => { + // `true` should not be accepted — that's a request to enable, but the + // shape doesn't carry the crypto material, so it's meaningless. + expectInvalid({ serverFunctions: true }, "serverFunctions"); + expectInvalid({ serverFunctions: 0 }, "serverFunctions"); + expectInvalid({ serverFunctions: "yes" }, "serverFunctions"); + }); + + it("rejects unknown nested keys when using the object form", () => { + expectInvalid( + { serverFunctions: { foobar: true } }, + "serverFunctions.foobar" + ); + }); +}); + +describe("config validation — remoteComponents: false (force-disable)", () => { + it("accepts the literal `false`", () => { + expectValid({ remoteComponents: false }); + }); + + it("rejects `true` (only `false` is meaningful — default already enabled)", () => { + expectInvalid({ remoteComponents: true }, "remoteComponents"); + }); + + it("rejects any other value", () => { + expectInvalid({ remoteComponents: 0 }, "remoteComponents"); + expectInvalid({ remoteComponents: "off" }, "remoteComponents"); + expectInvalid({ remoteComponents: {} }, "remoteComponents"); + }); +});