Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion docs/src/pages/en/(pages)/guide/server-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,92 @@ 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 framework 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.

<Link name="security">
## Security
</Link>

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.

<Link name="action-identity-and-bound-captures">
### Action identity and bound captures
</Link>

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 }) {
return (
<form
action={async (formData) => {
"use server";
await db.users.update(userId, formData.get("name"));
}}
>
</form>
);
}
```

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:

- 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

<Link name="security-configuration">
### Configuration
</Link>

There is one configuration property to set: a stable encryption secret. Without it, the runtime generates an ephemeral key per process — fine for development, but tokens won't survive a restart or be valid across multiple instances of the server.

```js
// react-server.config.mjs
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET, // 32-byte hex, or any string (hashed to 32 bytes)
},
};
```

The key resolves in this order:

1. `REACT_SERVER_FUNCTIONS_SECRET` environment variable
2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` environment variable (path to a file)
3. `serverFunctions.secret` in the runtime config
4. `serverFunctions.secretFile` in the runtime config (path to a file)
5. A random ephemeral key (development fallback only)

<Link name="key-rotation">
### Key rotation
</Link>

To rotate without invalidating in-flight tokens, list the prior keys under `serverFunctions.previousSecrets` (or `serverFunctions.previousSecretFiles`). Incoming tokens are tried against the primary key first and then each previous key in turn:

```js
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET,
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
},
};
```

The same rotation applies to both action identity and bound captures — they share one key.

<Link name="client-side-bind">
### Client-side `.bind()`
</Link>

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.

<Link name="security-limitations">
### Limitations
</Link>

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.
91 changes: 90 additions & 1 deletion docs/src/pages/ja/(pages)/guide/server-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,93 @@ export default function App() {
<MyClientComponent action={action} />
</div>
);
}
}
```

<Link name="security">
## セキュリティ
</Link>

サーバ関数の呼び出しはネットワーク越しのラウンドトリップを経由します。ランタイムは関数への参照を発行し、クライアントがそれを呼び出し、ランタイムがその関数をサーバ側で解決して実行します。このラウンドトリップにおいて改ざん検知可能であるべきものが二つあります。呼び出される対象アクションの **ID** と、それに伴って渡される **キャプチャされた値** です。

<Link name="action-identity-and-bound-captures">
### アクション ID とバインドされたキャプチャ
</Link>

すべてのサーバ関数の参照は、単一の AES-256-GCM トークンとしてエンコードされます。このトークンの平文は `(actionId, bound)` のペアであり、`bound` はレンダリング時に `.bind(...)` またはインラインクロージャによってキャプチャされた値の配列、もしくは引数を持たないアクションの場合は `null` です。クライアントが目にするのは暗号文のみで、アクションの呼び出し時にもこの暗号文がそのまま送り返されます。

```jsx
function ProfilePage({ userId }) {
return (
<form
action={async (formData) => {
"use server";
await db.users.update(userId, formData.get("name"));
}}
>
</form>
);
}
```

上記の例では、`userId` がインラインのサーバ関数によってキャプチャされています。ランタイムは、アクションの ID とキャプチャされた `userId` の両方を一つのトークンに束ねて発行します。クライアントは `userId` を平文で目にすることはなく、別の値としてラウンドトリップさせることもできず、トークンの認証タグを無効化せずに編集することはできません — 認証タグが壊れた場合、その呼び出しは拒否されます。

これはサーバ関数のあらゆる形式に適用されます:

- モジュールスコープの `"use server"` 関数 (キャプチャなし → bound は `null`)
- レンダリング時にキャプチャを行うインラインクロージャ
- サーバ関数を部分適用するためのサーバ側 `.bind(...)` の利用
- 他のサーバ関数の引数として渡されるバインド済みのサーバ関数参照

<Link name="security-configuration">
### 設定
</Link>

設定すべきプロパティは一つだけです — 永続化された暗号化シークレットです。これがない場合、ランタイムはプロセスごとに一時的な鍵を生成します。開発時には問題ありませんが、サーバの再起動を跨ぐトークンや、複数インスタンス間で有効なトークンは得られません。

```js
// react-server.config.mjs
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET, // 32 バイトの hex 文字列、または任意の文字列 (32 バイトにハッシュされます)
},
};
```

鍵は次の順序で解決されます:

1. `REACT_SERVER_FUNCTIONS_SECRET` 環境変数
2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` 環境変数 (ファイルへのパス)
3. ランタイム設定の `serverFunctions.secret`
4. ランタイム設定の `serverFunctions.secretFile` (ファイルへのパス)
5. ランダムな一時的な鍵 (開発時のフォールバックのみ)

<Link name="key-rotation">
### 鍵のローテーション
</Link>

処理中のトークンを失効させずに鍵をローテーションするには、以前の鍵を `serverFunctions.previousSecrets` (またはファイルの場合は `serverFunctions.previousSecretFiles`) に列挙します。受信したトークンはまず主鍵で検証され、その後、各以前の鍵が順に試されます:

```js
export default {
serverFunctions: {
secret: process.env.ACTION_SECRET,
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
},
};
```

同じローテーションがアクション ID とバインドされたキャプチャの両方に適用されます — 鍵は一つだけです。

<Link name="client-side-bind">
### クライアントサイドの `.bind()`
</Link>

バインド済みのサーバ関数がクライアントコンポーネントに渡され、クライアントがさらに `.bind(...)` を呼んで引数を追加した場合、これらの追加引数は新しいキャプチャではなく **ランタイム引数** として扱われます。これらは通常の呼び出し引数 (ユーザが呼び出し時に渡すものと同様) として送られ、暗号化されたトークンには含まれません。これは意図的な仕様です — サーバから発行されたバインドのみが整合性で保護され、クライアントが追加した引数は実質的にクライアントが呼び出し時に送ることを選んだ値に過ぎません。

<Link name="security-limitations">
### 制限事項
</Link>

トークンはバインド配列内の値を保護します。キャプチャされた値が `File` や `Blob` の場合、トークンはバイナリコンテンツへのスロット参照を保護しますが、バイナリコンテンツそのものは保護しません。サーバ側で構築したバイナリデータをクロージャにバインドするサーバ関数は、有効なトークンであっても、アップロードを制御する攻撃者によってバイナリコンテンツが差し替えられる可能性があることに注意してください。実際にはサーバ関数のクロージャでキャプチャされる `File` / `Blob` はまれです。
160 changes: 144 additions & 16 deletions packages/react-server/server/action-crypto.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
} from "node:crypto";
import { readFile } from "node:fs/promises";

import { syncToBuffer } from "@lazarv/rsc/server";
import { syncFromBuffer } from "@lazarv/rsc/client";

let resolvedKey = null;
let previousKeys = [];

Expand Down Expand Up @@ -193,21 +196,56 @@ function getPreviousKeys() {
}

/**
* Encrypt a server function ID using AES-256-GCM with a random IV.
* Encrypt an action token (id + optional bound capture array) using AES-256-GCM
* with a random IV.
*
* Bound captures travel inside the encrypted blob using `@lazarv/rsc`'s
* `syncToBuffer` — the same wire format `decodeReply` speaks — so typed
* values (`Date`, `BigInt`, `Map`, `Set`, `RegExp`, `URL`, `URLSearchParams`,
* typed arrays, …) survive the round-trip with full fidelity. A naive
* `JSON.stringify` on `bound` would silently lose those types.
*
* Bundling `(actionId, boundBytes)` into a single AEAD-protected token gives
* us tamper-evident bound captures for free: the same primitive that binds
* action identity also binds the captured arguments. Bound values never
* travel plaintext on the wire, and a malicious client cannot edit them
* without invalidating the auth tag.
*
* Each call produces a unique ciphertext (random IV), so every render emits
* fresh tokens even for the same `(actionId, bound)` pair.
*
* Each call produces a unique token because the IV is randomly generated.
* This means every render produces fresh, unique action tokens.
* Plaintext layout (post-decrypt):
*
* @param {string} actionId - The original action ID (e.g. "src/actions#submitForm")
* `[actionId, boundBytesAsBase64 | null]`
*
* - `null` → unbound action.
* - base64 string → decode to bytes, then `syncFromBuffer` to recover the
* typed bound array.
*
* @param {string} actionId - The original action ID (e.g. "src/actions#submit")
* @param {Array<unknown> | null | undefined} [bound] - Captured bound args, or null/undefined for unbound
* @returns {string} base64url-encoded encrypted token
*/
export function encryptActionId(actionId) {
export function encryptActionToken(actionId, bound) {
const key = getKey();
const iv = randomBytes(12);

// Serialize the bound array via @lazarv/rsc's sync flight encoder so
// typed values survive the round-trip. Returns null when the action
// has no bound captures (or an empty array — same wire result either way).
let boundEncoded = null;
if (Array.isArray(bound) && bound.length > 0) {
const buffer = syncToBuffer(bound);
boundEncoded = Buffer.from(buffer).toString("base64");
}

// Plaintext is JSON [actionId, base64Bytes | null]. Array (not object)
// form keeps the shape stable and avoids JSON-key-ordering ambiguity.
const plaintext = JSON.stringify([actionId, boundEncoded]);

const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted = Buffer.concat([
cipher.update(actionId, "utf8"),
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
Expand All @@ -216,6 +254,19 @@ export function encryptActionId(actionId) {
return Buffer.concat([iv, authTag, encrypted]).toString("base64url");
}

/**
* Encrypt a server function ID (no bound captures). Thin wrapper over
* `encryptActionToken(actionId, null)` kept for callers that don't carry
* bound state. Emits the same array-form plaintext, so a token produced
* here decrypts cleanly via either `decryptActionId` or `decryptActionToken`.
*
* @param {string} actionId
* @returns {string} base64url-encoded encrypted token
*/
export function encryptActionId(actionId) {
return encryptActionToken(actionId, null);
}

/**
* Try to decrypt a token with a specific key.
*
Expand Down Expand Up @@ -248,28 +299,105 @@ function tryDecryptWithKey(token, key) {
}

/**
* Decrypt an encrypted action token back to the original action ID.
* Parse a decrypted plaintext back into `{ actionId, bound }`.
*
* Handles two formats:
*
* Tries the primary key first, then falls back to previous keys (rotation).
* - **New (array)**: `[actionId, boundBase64 | null]` — emitted by every
* token issued since type-preserving bound landed. `boundBase64` is
* either `null` (unbound) or a base64-encoded `syncToBuffer` blob that
* decodes to the typed bound array via `syncFromBuffer`.
* - **Legacy (plain string)**: just the action id, no bound. Tokens that
* pre-date this change (e.g. still in flight from a pre-upgrade render)
* decrypt cleanly to `{ actionId, bound: null }`.
*
* Returns `null` on any structural inconsistency, including a base64 blob
* that doesn't survive `syncFromBuffer` (corruption, version skew).
*
* @param {string} plaintext
* @returns {{actionId: string, bound: Array<unknown> | null} | null}
*/
function parseTokenPlaintext(plaintext) {
// Legacy: action id as a plain string. Any token that wasn't
// JSON.stringified as an array starts with a non-bracket character.
if (plaintext.length > 0 && plaintext[0] !== "[") {
return { actionId: plaintext, bound: null };
}
let parsed;
try {
parsed = JSON.parse(plaintext);
} catch {
return null;
}
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== "string"
) {
return null;
}
const actionId = parsed[0];
const boundEncoded = parsed[1];

if (boundEncoded === null) {
return { actionId, bound: null };
}

if (typeof boundEncoded !== "string") return null;

// Decode the bound bytes via @lazarv/rsc's sync flight decoder. This
// recovers typed values (Date, BigInt, Map, Set, RegExp, URL,
// URLSearchParams, typed arrays, …) with full fidelity — the same
// contract decodeReply gives us for client-supplied args.
let bound;
try {
const bytes = Buffer.from(boundEncoded, "base64");
bound = syncFromBuffer(bytes);
} catch {
return null;
}
if (!Array.isArray(bound)) return null;

return { actionId, bound };
}

/**
* Decrypt an action token back to its full `{ actionId, bound }` payload.
*
* Tries the primary key first, then any rotation keys. Returns `null` if
* decryption fails (wrong key, tampered ciphertext, malformed plaintext).
*
* @param {string} token - base64url-encoded encrypted token
* @returns {string | null} The original action ID, or null if decryption fails
* @returns {{actionId: string, bound: Array<unknown> | null} | null}
*/
export function decryptActionId(token) {
export function decryptActionToken(token) {
if (!token || typeof token !== "string") return null;

const key = getKey();

// Try primary key, then previous keys for rotation.
const keysToTry = [key, ...getPreviousKeys()];
const keysToTry = [getKey(), ...getPreviousKeys()];
for (const k of keysToTry) {
const result = tryDecryptWithKey(token, k);
if (result !== null) return result;
const plaintext = tryDecryptWithKey(token, k);
if (plaintext !== null) {
return parseTokenPlaintext(plaintext);
}
}

return null;
}

/**
* Decrypt an action token to just the action ID (drops any bound payload).
*
* Convenience for callers that only need the action identity — registry
* lookups, logging, etc. Returns `null` on failure.
*
* @param {string} token
* @returns {string | null}
*/
export function decryptActionId(token) {
const result = decryptActionToken(token);
return result ? result.actionId : null;
}

/**
* Wrap a server reference map (Proxy or static object) with a layer that
* transparently handles encrypted action ID lookups.
Expand Down
Loading
Loading