diff --git a/docs/src/pages/en/(pages)/framework/server-function-encryption.mdx b/docs/src/pages/en/(pages)/framework/server-function-encryption.mdx new file mode 100644 index 00000000..ab6cb6e1 --- /dev/null +++ b/docs/src/pages/en/(pages)/framework/server-function-encryption.mdx @@ -0,0 +1,145 @@ +--- +title: Server function encryption +category: Framework +order: 3 +--- + +import Link from "../../../../components/Link.jsx"; + +# Server function encryption + +`@lazarv/react-server` encrypts all server function identifiers by default using **AES-256-GCM** encryption. This prevents clients from discovering or calling server functions that were not explicitly exposed during rendering. + +Without encryption, server function identifiers are plain-text strings that reveal your source file paths and function names (e.g. `src/actions#deleteUser`). Any client could craft a request to invoke any server function in your application, even functions that were never rendered for that user. Encryption ensures that only server functions returned by the server during rendering are callable. + + +## How it works + + +There are two types of server function tokens: + +**Inline tokens** are generated when a server function reference is first used. The encrypted token is cached on the function instance so that React's internal form state matching (e.g. `useActionState`) works correctly. Inline server functions and server functions passed as props to client components use these tokens, which are embedded in the RSC stream. + +**Static tokens** are generated at build time (or Vite transform time in development) for server function modules — files with `"use server"` at the top level. These tokens are embedded in the client JavaScript bundle and are used when client components import server functions directly. + +Both types are encrypted with the same key and decrypted transparently on the server when a server function is invoked. + + +## Zero configuration + + +Encryption works out of the box with no configuration needed: + +- **In development**, an ephemeral key is generated automatically when the dev server starts. +- **In production builds**, a random 32-byte secret is generated during the build and stored as a build artifact. The production server loads this key on startup. + +This means server function encryption is always active. You only need to configure it if you want to provide your own secret or enable key rotation. + + +## Custom secret + + +You can provide your own encryption secret using environment variables or the framework configuration. This is useful when you need deterministic keys across deployments or when running multiple server instances. + +The secret is resolved in the following priority order: + +1. `REACT_SERVER_FUNCTIONS_SECRET` environment variable +2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` environment variable (path to a key file) +3. `serverFunctions.secret` in the framework configuration +4. `serverFunctions.secretFile` in the framework configuration (path to a key file) +5. Build artifact (generated automatically during production builds) +6. Ephemeral random key (development fallback) + + +### Environment variable + + +```sh +REACT_SERVER_FUNCTIONS_SECRET=my-secret-key pnpm start +``` + +Or point to a key file: + +```sh +REACT_SERVER_FUNCTIONS_SECRET_FILE=./keys/action-secret.pem pnpm start +``` + + +### Framework configuration + + +```mjs filename="react-server.config.mjs" +export default { + serverFunctions: { + secret: "my-secret-key", + }, +}; +``` + +Or using a key file: + +```mjs filename="react-server.config.mjs" +export default { + serverFunctions: { + secretFile: "./keys/action-secret.pem", + }, +}; +``` + +> Environment variables and configuration values always take priority over the build artifact. This allows you to rotate secrets without rebuilding your application. + + +## Key rotation + + +When you rotate your encryption secret, clients that still have pages open with tokens encrypted using the old key would get errors. To avoid this, you can provide previous secrets that the server will try as fallbacks during decryption. + +```mjs filename="react-server.config.mjs" +export default { + serverFunctions: { + secret: "new-secret-key", + previousSecrets: ["old-secret-key"], + }, +}; +``` + +You can also use key files for previous secrets: + +```mjs filename="react-server.config.mjs" +export default { + serverFunctions: { + secret: "new-secret-key", + previousSecretFiles: ["./keys/old-secret.pem"], + }, +}; +``` + +Both `previousSecrets` and `previousSecretFiles` accept arrays. You can combine them to support multiple previous keys simultaneously. + +The rotation workflow is: + +1. Add your current secret to `previousSecrets` +2. Set a new value for `secret` +3. Deploy — all existing tokens still work via the fallback +4. After all clients have refreshed (e.g. after a deployment window), remove the old secret from `previousSecrets` + + +## Error handling + + +When a server function invocation fails decryption — for example, because the token was tampered with, the key was rotated without providing the previous key, or the token is otherwise invalid — the server throws a `ServerFunctionNotFoundError`. This error is propagated through RSC to the client, where it can be caught using an error boundary. + +This prevents leaking information about which server functions exist in your application. + + +## Security model + + +The encryption provides the following security properties: + +- **Server function hiding** — clients cannot discover server function file paths or names from the encrypted tokens. +- **Capability protection** — clients can only invoke server functions that the server explicitly exposed during rendering or bundled into the client code. They cannot forge tokens for arbitrary server functions. +- **Token uniqueness** — each server function reference receives a unique encrypted token (random initialization vector), making tokens unpredictable. +- **Key rotation** — secrets can be rotated at runtime without downtime or rebuilds by using the `previousSecrets` configuration. + +> Encryption protects _which_ server functions are callable. It does not validate the _arguments_ passed to those functions. Always validate and sanitize inputs inside your server functions. diff --git a/packages/react-server/dist/server/action-secret.mjs b/packages/react-server/dist/server/action-secret.mjs new file mode 100644 index 00000000..94aaf35d --- /dev/null +++ b/packages/react-server/dist/server/action-secret.mjs @@ -0,0 +1,5 @@ +import { importDist } from "@lazarv/react-server/dist/import"; + +const mod = await importDist("server/action-secret.mjs"); + +export default mod.default; diff --git a/packages/react-server/lib/build/action.mjs b/packages/react-server/lib/build/action.mjs index ee617174..0e83abc6 100644 --- a/packages/react-server/lib/build/action.mjs +++ b/packages/react-server/lib/build/action.mjs @@ -111,6 +111,18 @@ export default async function build(root, options) { }); } + // Generate the action encryption secret early so the + // use-server plugin can encrypt action IDs in client/SSR stubs. + const { initSecret, initSecretFromConfig, generateSecret } = + await import("../../server/action-crypto.mjs"); + // Generate a fresh secret and set it as the baseline key. + const actionSecret = generateSecret(); + initSecret(actionSecret); + // Let user-provided secret (env var, config, .pem) override + // the generated key. At runtime the same env/config will + // override the build artifact, keeping the keys in sync. + await initSecretFromConfig(config[CONFIG_ROOT]); + // Create event bus for parallel builds // This allows RSC build to emit client component entries // that SSR and Client builds consume dynamically @@ -176,6 +188,14 @@ export default async function build(root, options) { "utf8" ); + // Persist the action encryption secret as a build artifact so it + // survives serverless cold starts and edge restarts. + await writeFile( + join(cwd, options.outDir, "server/action-secret.mjs"), + `export default ${JSON.stringify(actionSecret)};\n`, + "utf8" + ); + if (options.edge) { // empty line console.log(); diff --git a/packages/react-server/lib/build/edge.mjs b/packages/react-server/lib/build/edge.mjs index 5f45cc58..8249cf3d 100644 --- a/packages/react-server/lib/build/edge.mjs +++ b/packages/react-server/lib/build/edge.mjs @@ -260,6 +260,10 @@ export default async function edgeBuild(root, options) { return sys.normalizePath( join(cwd, options.outDir, "server/build-manifest.mjs") ); + case "@lazarv/react-server/dist/server/action-secret": + return sys.normalizePath( + join(cwd, options.outDir, "server/action-secret.mjs") + ); } }, load(id) { @@ -362,6 +366,10 @@ export default async function edgeBuild(root, options) { return sys.normalizePath( join(cwd, options.outDir, "server/build-manifest.mjs") ); + case "@lazarv/react-server/dist/server/action-secret": + return sys.normalizePath( + join(cwd, options.outDir, "server/action-secret.mjs") + ); } }, load(id) { diff --git a/packages/react-server/lib/dev/action.mjs b/packages/react-server/lib/dev/action.mjs index 8feee609..d224e065 100644 --- a/packages/react-server/lib/dev/action.mjs +++ b/packages/react-server/lib/dev/action.mjs @@ -90,6 +90,12 @@ export default async function dev(root, options) { runtime$(CONFIG_CONTEXT, config); + // Resolve the action encryption secret once at startup + // (from env vars, config, or .pem file — not per-render). + const { initSecretFromConfig } = + await import("../../server/action-crypto.mjs"); + await initSecretFromConfig(configRoot); + const isNonInteractiveEnvironment = !process.stdin.isTTY || process.env.CI === "true" || diff --git a/packages/react-server/lib/dev/index.mjs b/packages/react-server/lib/dev/index.mjs index 37a1193c..af8149c8 100644 --- a/packages/react-server/lib/dev/index.mjs +++ b/packages/react-server/lib/dev/index.mjs @@ -15,6 +15,13 @@ export function reactServer(root, options = {}, initialConfig = {}) { await runtime_init$(async () => { runtime$(CONFIG_CONTEXT, config); + + // Resolve the action encryption secret once at startup. + const { initSecretFromConfig } = + await import("../../server/action-crypto.mjs"); + const { CONFIG_ROOT } = await import("../../server/symbols.mjs"); + await initSecretFromConfig(config[CONFIG_ROOT]); + const server = await createServer(root, options); if (config.server?.hmr !== false) server.ws.listen(); resolve(server); diff --git a/packages/react-server/lib/loader/bun.mjs b/packages/react-server/lib/loader/bun.mjs index adc1621f..aa2a823d 100644 --- a/packages/react-server/lib/loader/bun.mjs +++ b/packages/react-server/lib/loader/bun.mjs @@ -69,6 +69,11 @@ export async function reactServerBunAliasPlugin(options) { outDir, "server/build-manifest.mjs" ), + "@lazarv/react-server/dist/server/action-secret": join( + cwd, + outDir, + "server/action-secret.mjs" + ), "@lazarv/react-server/dist/server/server-manifest": manifestLoaderPath, "@lazarv/react-server/dist/server/client-manifest": manifestLoaderPath, "@lazarv/react-server/dist/client/browser-manifest": manifestLoaderPath, diff --git a/packages/react-server/lib/loader/deno.mjs b/packages/react-server/lib/loader/deno.mjs index a2d9a1d5..8d8a66c9 100644 --- a/packages/react-server/lib/loader/deno.mjs +++ b/packages/react-server/lib/loader/deno.mjs @@ -69,6 +69,11 @@ export async function generateDenoImportMap(options = {}) { outDir, "server/build-manifest.mjs" ), + "@lazarv/react-server/dist/server/action-secret": join( + cwd, + outDir, + "server/action-secret.mjs" + ), "@lazarv/react-server/dist/server/server-manifest": manifestLoaderPath, "@lazarv/react-server/dist/server/client-manifest": manifestLoaderPath, "@lazarv/react-server/dist/client/browser-manifest": manifestLoaderPath, diff --git a/packages/react-server/lib/loader/node-loader.mjs b/packages/react-server/lib/loader/node-loader.mjs index fe1ab4ad..9bea8030 100644 --- a/packages/react-server/lib/loader/node-loader.mjs +++ b/packages/react-server/lib/loader/node-loader.mjs @@ -39,6 +39,14 @@ export async function resolve(specifier, context, nextResolve) { return nextResolve( pathToFileURL(join(cwd, outDir, "server/build-manifest.mjs")).href ); + case "@lazarv/react-server/dist/server/action-secret": + try { + return await nextResolve( + pathToFileURL(join(cwd, outDir, "server/action-secret.mjs")).href + ); + } catch { + return nextResolve("@lazarv/react-server/dist/server/action-secret"); + } case "@lazarv/react-server/dist/server/server-manifest": case "@lazarv/react-server/dist/server/client-manifest": case "@lazarv/react-server/dist/client/browser-manifest": diff --git a/packages/react-server/lib/loader/node-loader.react-server.mjs b/packages/react-server/lib/loader/node-loader.react-server.mjs index be30d33f..3735bbf8 100644 --- a/packages/react-server/lib/loader/node-loader.react-server.mjs +++ b/packages/react-server/lib/loader/node-loader.react-server.mjs @@ -84,6 +84,14 @@ export async function resolve(specifier, context, nextResolve) { return nextResolve( pathToFileURL(join(cwd, outDir, "server/build-manifest.mjs")).href ); + case "@lazarv/react-server/dist/server/action-secret": + try { + return await nextResolve( + pathToFileURL(join(cwd, outDir, "server/action-secret.mjs")).href + ); + } catch { + return nextResolve("@lazarv/react-server/dist/server/action-secret"); + } case "@lazarv/react-server/dist/server/server-manifest": case "@lazarv/react-server/dist/server/client-manifest": case "@lazarv/react-server/dist/client/browser-manifest": diff --git a/packages/react-server/lib/plugins/use-server.mjs b/packages/react-server/lib/plugins/use-server.mjs index 03b9e195..f3e41313 100644 --- a/packages/react-server/lib/plugins/use-server.mjs +++ b/packages/react-server/lib/plugins/use-server.mjs @@ -1,5 +1,6 @@ import { extname, relative } from "node:path"; +import { encryptActionId } from "../../server/action-crypto.mjs"; import * as sys from "../sys.mjs"; import { codegen, parse } from "../utils/ast.mjs"; @@ -159,7 +160,7 @@ export default function useServer(type, manifest) { arguments: [ { type: "Literal", - value: `${actionId}#${name}`, + value: encryptActionId(`${actionId}#${name}`), }, ], }, @@ -219,7 +220,7 @@ export default function useServer(type, manifest) { arguments: [ { type: "Literal", - value: `${actionId}#${name}`, + value: encryptActionId(`${actionId}#${name}`), }, { type: "Identifier", diff --git a/packages/react-server/lib/start/manifest.mjs b/packages/react-server/lib/start/manifest.mjs index b215784e..960bd949 100644 --- a/packages/react-server/lib/start/manifest.mjs +++ b/packages/react-server/lib/start/manifest.mjs @@ -1,10 +1,12 @@ import { join } from "node:path"; import { getContext } from "../../server/context.mjs"; -import { runtime$ } from "../../server/runtime.mjs"; +import { getRuntime, runtime$ } from "../../server/runtime.mjs"; import { COLLECT_CLIENT_MODULES, COLLECT_STYLESHEETS, + CONFIG_CONTEXT, + CONFIG_ROOT, HTTP_CONTEXT, MAIN_MODULE, MANIFEST, @@ -76,6 +78,30 @@ export async function init$(options = {}) { // build-manifest may not exist for older builds } + // Load action encryption secret from the build artifact. + // Users can override via env var or config (resolved in initSecretFromConfig). + try { + const { default: actionSecret } = + await import("@lazarv/react-server/dist/server/action-secret"); + if (actionSecret) { + const { initSecret } = await import("../../server/action-crypto.mjs"); + initSecret(actionSecret); + } + } catch { + // action-secret may not exist for builds without server functions + } + + // Resolve the action encryption secret from env vars / config / .pem file. + // This runs once at startup — env/config take priority over the build artifact. + try { + const { initSecretFromConfig } = + await import("../../server/action-crypto.mjs"); + const config = getRuntime(CONFIG_CONTEXT); + await initSecretFromConfig(config?.[CONFIG_ROOT]); + } catch { + // ignore + } + const mainModule = `/${Object.values(manifest.browser).find((entry) => entry.name === "index")?.file}`; runtime$(MAIN_MODULE, [mainModule]); diff --git a/packages/react-server/server/action-crypto.mjs b/packages/react-server/server/action-crypto.mjs new file mode 100644 index 00000000..fa4ff437 --- /dev/null +++ b/packages/react-server/server/action-crypto.mjs @@ -0,0 +1,309 @@ +import { + randomBytes, + createCipheriv, + createDecipheriv, + createHash, +} from "node:crypto"; +import { readFile } from "node:fs/promises"; + +let resolvedKey = null; +let previousKeys = []; + +/** + * Derive a 32-byte AES key from an arbitrary secret. + * Accepts hex strings, base64url strings, or raw bytes. + */ +function deriveKey(secret) { + if (Buffer.isBuffer(secret)) { + return secret.length === 32 + ? secret + : createHash("sha256").update(secret).digest(); + } + if (typeof secret === "string") { + // Try hex (64-char string = 32 bytes) + if (/^[0-9a-fA-F]{64}$/.test(secret)) { + return Buffer.from(secret, "hex"); + } + // Otherwise hash the raw string to get a consistent 32-byte key + return createHash("sha256").update(secret, "utf8").digest(); + } + throw new Error("Invalid secret: expected a string or Buffer"); +} + +/** + * Load the secret from a .pem file (async). + * Reads the file and hashes its contents to a 32-byte key. + */ +async function loadSecretFile(filePath) { + const contents = await readFile(filePath); + return createHash("sha256").update(contents).digest(); +} + +/** + * Initialise the encryption key from configuration, env vars, or .pem files. + * + * Must be called **once** at server startup (not per-render). Resolution order: + * + * 1. `REACT_SERVER_FUNCTIONS_SECRET` environment variable + * 2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` env var (path to .pem) + * 3. `serverFunctions.secret` in react-server config + * 4. `serverFunctions.secretFile` in react-server config (path to .pem) + * 5. Fallback: generate a random ephemeral key (dev mode) + * + * In production the build artifact is loaded separately via `initSecret()` + * before this function is called, so steps 1–4 act as overrides. + * + * @param {object} [config] - The react-server user config object (optional) + */ +export async function initSecretFromConfig(config) { + // Env vars and config deliberately override a key that was already set + // via initSecret() (e.g. from a build artifact) so that operators can + // rotate secrets without rebuilding. + let secretSet = false; + + // 1. Env var — direct secret + const envSecret = + typeof process !== "undefined" + ? process.env?.REACT_SERVER_FUNCTIONS_SECRET + : undefined; + if (envSecret) { + resolvedKey = deriveKey(envSecret); + globalThis.__react_server_action_key__ = resolvedKey; + secretSet = true; + } + + // 2. Env var — secret file + if (!secretSet) { + const envFile = + typeof process !== "undefined" + ? process.env?.REACT_SERVER_FUNCTIONS_SECRET_FILE + : undefined; + if (envFile) { + resolvedKey = await loadSecretFile(envFile); + globalThis.__react_server_action_key__ = resolvedKey; + secretSet = true; + } + } + + // 3. Config — direct secret + if (!secretSet) { + const configSecret = config?.serverFunctions?.secret; + if (configSecret) { + resolvedKey = deriveKey(configSecret); + globalThis.__react_server_action_key__ = resolvedKey; + secretSet = true; + } + } + + // 4. Config — secret file + if (!secretSet) { + const configFile = config?.serverFunctions?.secretFile; + if (configFile) { + resolvedKey = await loadSecretFile(configFile); + globalThis.__react_server_action_key__ = resolvedKey; + secretSet = true; + } + } + + // No user-provided secret found — leave resolvedKey as-is. + // In dev mode getKey() will lazily generate an ephemeral key. + + // --- Previous keys for rotation --- + const prevSecrets = config?.serverFunctions?.previousSecrets; + const prevFiles = config?.serverFunctions?.previousSecretFiles; + const prev = []; + if (Array.isArray(prevSecrets)) { + for (const s of prevSecrets) { + if (s) prev.push(deriveKey(s)); + } + } + if (Array.isArray(prevFiles)) { + for (const f of prevFiles) { + if (f) prev.push(await loadSecretFile(f)); + } + } + if (prev.length > 0) { + previousKeys = prev; + globalThis.__react_server_action_previous_keys__ = previousKeys; + } +} + +/** + * Initialise the secret from an externally-provided value. + * Called at build time (with a generated secret) and at production startup + * (with the build artifact). Always sets the key — callers that need to + * override (env var, config) should call initSecretFromConfig() afterwards. + * + * The key is also stored on `globalThis` so that separate module instances + * of this file (e.g. Vite plugin vs. Vite SSR module graph in dev mode) + * can converge on the same encryption key. + * + * @param {string | Buffer} secret + */ +export function initSecret(secret) { + resolvedKey = deriveKey(secret); + globalThis.__react_server_action_key__ = resolvedKey; +} + +/** + * Generate a random 32-byte secret (hex-encoded). + * Used at build time to produce a persistent key. + * + * @returns {string} 64-char hex string + */ +export function generateSecret() { + return randomBytes(32).toString("hex"); +} + +/** + * Return the current key. + * + * Checks `globalThis.__react_server_action_key__` first so that a key + * initialised in one module instance (e.g. the Vite plugin) is visible to + * other instances of this file loaded through a different module graph + * (e.g. Vite's SSR / RSC module system in dev mode). + * + * Falls back to generating a random ephemeral key for edge cases (tests). + */ +function getKey() { + if (!resolvedKey && globalThis.__react_server_action_key__) { + resolvedKey = globalThis.__react_server_action_key__; + } + if (!resolvedKey) { + // Fallback for edge cases where init was skipped (e.g. tests). + resolvedKey = randomBytes(32); + globalThis.__react_server_action_key__ = resolvedKey; + } + // Sync previous keys from globalThis (cross-instance). + if ( + previousKeys.length === 0 && + globalThis.__react_server_action_previous_keys__?.length > 0 + ) { + previousKeys = globalThis.__react_server_action_previous_keys__; + } + return resolvedKey; +} + +/** + * Return the list of previous keys for rotation. + */ +function getPreviousKeys() { + getKey(); // ensure synced from globalThis + return previousKeys; +} + +/** + * Encrypt a server function ID using AES-256-GCM with a random IV. + * + * Each call produces a unique token because the IV is randomly generated. + * This means every render produces fresh, unique action tokens. + * + * @param {string} actionId - The original action ID (e.g. "src/actions#submitForm") + * @returns {string} base64url-encoded encrypted token + */ +export function encryptActionId(actionId) { + const key = getKey(); + const iv = randomBytes(12); + + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([ + cipher.update(actionId, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + // Format: iv(12) + authTag(16) + ciphertext + return Buffer.concat([iv, authTag, encrypted]).toString("base64url"); +} + +/** + * Try to decrypt a token with a specific key. + * + * @param {string} token - base64url-encoded encrypted token + * @param {Buffer} key - 32-byte AES key + * @returns {string | null} The decrypted plaintext, or null on failure + */ +function tryDecryptWithKey(token, 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); + + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return decrypted.toString("utf8"); + } catch { + return null; + } +} + +/** + * Decrypt an encrypted action token back to the original action ID. + * + * Tries the primary key first, then falls back to previous keys (rotation). + * + * @param {string} token - base64url-encoded encrypted token + * @returns {string | null} The original action ID, or null if decryption fails + */ +export function decryptActionId(token) { + if (!token || typeof token !== "string") return null; + + const key = getKey(); + + // Try primary key, then previous keys for rotation. + const keysToTry = [key, ...getPreviousKeys()]; + for (const k of keysToTry) { + const result = tryDecryptWithKey(token, k); + if (result !== null) return result; + } + + return null; +} + +/** + * Wrap a server reference map (Proxy or static object) with a layer that + * transparently handles encrypted action ID lookups. + * + * When a lookup key cannot be found directly, the wrapper attempts to decrypt + * it and retries the lookup with the decrypted value. + * + * @param {object} baseMap - The original server reference map + * @returns {Proxy} A wrapped map that supports encrypted key lookups + */ +export function wrapServerReferenceMap(baseMap) { + return new Proxy(baseMap, { + get(target, prop) { + if (typeof prop === "symbol") return target[prop]; + + // Standard action ID keys (contain "#") — delegate directly. + if (typeof prop === "string" && prop.includes("#")) { + return target[prop]; + } + + // server-action:// prefixed keys used for RSC serialization proxying + if (typeof prop === "string" && prop.startsWith("server-action://")) { + return target[prop]; + } + + // Attempt to decrypt (potential encrypted token). + if (typeof prop === "string") { + const decrypted = decryptActionId(prop); + if (decrypted) { + return target[decrypted]; + } + } + + return undefined; + }, + }); +} diff --git a/packages/react-server/server/action-register.mjs b/packages/react-server/server/action-register.mjs index 0fbf5fd0..286abbea 100644 --- a/packages/react-server/server/action-register.mjs +++ b/packages/react-server/server/action-register.mjs @@ -1,3 +1,47 @@ -import { registerServerReference } from "react-server-dom-webpack/server.edge"; +import { registerServerReference as _registerServerReference } from "react-server-dom-webpack/server.edge"; +import { encryptActionId } from "./action-crypto.mjs"; -export { registerServerReference }; +/** + * Wraps React's registerServerReference to replace the plain-text $$id with + * an encrypting getter. The encrypted token is cached on the function + * instance so that every read of $$id within the same lifecycle returns the + * same value. This is required because React's decodeFormState compares the + * action ID from the submitted form with the current $$id — if they differ + * (e.g. due to a fresh random IV on each read), useActionState cannot match + * the form state back to the component. + */ +export function registerServerReference(fn, id, name) { + // Let React set up $$typeof, $$id, $$bound, bind, etc. + _registerServerReference(fn, id, name); + + // Capture the original plain-text ID that React just set + const originalId = fn.$$id; + + // Store the original plain-text ID as a non-enumerable property so that + // server-side code (e.g. useActionState) can compare action identities + // without needing to decrypt. Non-enumerable keeps it out of + // serialisation payloads sent to the client. + Object.defineProperty(fn, "$$originalId", { + value: originalId, + enumerable: false, + configurable: false, + writable: false, + }); + + // Replace $$id with a getter that lazily encrypts once and caches the + // result. The token is still unique per function instance (random IV on + // first read) but stable across subsequent reads of the same reference. + let cachedEncryptedId = null; + Object.defineProperty(fn, "$$id", { + get() { + if (!cachedEncryptedId) { + cachedEncryptedId = encryptActionId(originalId); + } + return cachedEncryptedId; + }, + enumerable: true, + configurable: true, + }); + + return fn; +} diff --git a/packages/react-server/server/action-state.mjs b/packages/react-server/server/action-state.mjs index e8bf7c98..0a115def 100644 --- a/packages/react-server/server/action-state.mjs +++ b/packages/react-server/server/action-state.mjs @@ -21,7 +21,15 @@ export function useActionState(action) { error: null, actionId: null, }; - if (actionId !== action.$$id && error?.name !== SERVER_FUNCTION_NOT_FOUND) { + // The context's actionId can be either: + // - the encrypted token (from React's decodeAction – form submissions) + // - the decrypted plain-text ID (from the React-Server-Action header path) + // Compare against both $$id (encrypted, cached) and $$originalId (plain + // text) so that either form of actionId is recognised as a match. + const isMatch = + actionId === action.$$id || + (action.$$originalId != null && actionId === action.$$originalId); + if (!isMatch && error?.name !== 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 b85591e5..92923084 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -51,7 +51,10 @@ import { import { ServerFunctionNotFoundError } from "./action-state.mjs"; import { cwd } from "../lib/sys.mjs"; import { clientReferenceMap } from "@lazarv/react-server/dist/server/client-reference-map"; -import { serverReferenceMap } from "@lazarv/react-server/dist/server/server-reference-map"; +import { serverReferenceMap as _serverReferenceMap } from "@lazarv/react-server/dist/server/server-reference-map"; +import { decryptActionId, wrapServerReferenceMap } from "./action-crypto.mjs"; + +const serverReferenceMap = wrapServerReferenceMap(_serverReferenceMap); export async function render(Component, props = {}, options = {}) { const logger = getContext(LOGGER_CONTEXT); @@ -153,11 +156,26 @@ export async function render(Component, props = {}, options = {}) { if (!(input instanceof Error)) { if (serverActionHeader && serverActionHeader !== "null") { - const [, serverReferenceName] = serverActionHeader.split("#"); + // Decrypt the capability-protected action ID. + // If decryption fails, fall back to the raw header value so + // that plain-text action IDs still work (e.g. during dev). + const decryptedId = decryptActionId(serverActionHeader); + const resolvedActionId = decryptedId ?? serverActionHeader; + const [, serverReferenceName] = resolvedActionId.split("#"); + + // Verify the action exists in the server reference map. + // When the ID was encrypted but decryption failed (invalid / + // tampered token) AND the raw header is also unknown, throw + // so RSC can propagate the error to the client. + const serverReference = serverReferenceMap[resolvedActionId]; + if (!serverReference) { + throw new ServerFunctionNotFoundError(); + } + action = async () => { try { const mod = await globalThis.__webpack_require__( - serverReferenceMap[serverActionHeader].id.replace( + serverReference.id.replace( /^server-action:\/\//, "server://" ) @@ -170,13 +188,13 @@ export async function render(Component, props = {}, options = {}) { const data = await boundFn(); return { data, - actionId: serverActionHeader, + actionId: resolvedActionId, error: null, }; } catch (error) { return { data: null, - actionId: serverActionHeader, + actionId: resolvedActionId, error, }; }