Skip to content

feat: server function encrypted bound args#421

Merged
lazarv merged 1 commit into
mainfrom
feat/server-function-encrypted-bound-args
May 8, 2026
Merged

feat: server function encrypted bound args#421
lazarv merged 1 commit into
mainfrom
feat/server-function-encrypted-bound-args

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented May 8, 2026

Summary

Server-emitted bound captures of server functions — the closure variables of inline "use server" functions and the arguments passed to a server-side .bind(...) — used to travel plaintext on the wire as part of the $h outlined chunk's bound array. A malicious client could submit a legitimate action token paired with attacker-chosen bound values, swapping a captured userId=42 for userId=99 and updating a different user's data while authenticated as someone else. Classic IDOR / bound-arg tampering.

This PR bundles every action's bound capture array into the same AES-256-GCM token that already protects action identity. Token plaintext becomes [actionId, boundBytesAsBase64], where the bound bytes come from @lazarv/rsc's sync flight encoder. Bound values never travel plaintext on the wire, and any tampering — at the token, the action id, or the bound payload — invalidates the GCM auth tag and the call is rejected before the action runs.

Why an AEAD primitive instead of a separate HMAC

The first attempt sat an HMAC tag alongside the plaintext bound on the wire, then bound (id, bound, sig) together at verification time. That approach had a fundamental architectural flaw: by the time the user clicks a bound action, callServer packages the bound prefix as positional args (not as a $h reference) so it lands in the call body indistinguishably from runtime args. There is no $h chunk in the call body to attach a sig to, and the dispatcher cannot tell which of the positional args were "bound" vs "user-supplied". Tampering would have been undetectable in the dominant code path.

Encoding the bound array inside the encrypted action token closes this by removing the bound prefix from the wire entirely. The client sends only runtime args; the server recovers bound by decrypting the token and prepends it before invoking the action. There is nothing for an attacker to tamper with.

Type fidelity is the load-bearing detail

A naive implementation would JSON.stringify the bound array into the token plaintext. That silently strips type information from Date, BigInt, Map, Set, RegExp, URL, URLSearchParams, typed arrays — every typed value the wire format already supports through decodeReply. After a round-trip, the action would receive a string where it expected a Date, etc. Bound captures are now routed through syncToBuffer / syncFromBuffer, the existing public sync flight serialization pair on @lazarv/rsc. Bound captures travel through the same $<tag> scheme that decodeReply already speaks for client-supplied args, so any typed value the framework supports anywhere else also survives bound-capture round-trip with full fidelity.

Implementation

packages/react-server/server/action-crypto.mjs gains encryptActionToken(actionId, bound) and decryptActionToken(token). The encrypt path runs bound through syncToBuffer to get a Uint8Array, base64-encodes it, and embeds it as the second element of the JSON plaintext [actionId, boundBytesAsBase64 | null]. The decrypt path inverts that: parse JSON, decode base64, run syncFromBuffer to recover the typed array. encryptActionId becomes a thin delegator over encryptActionToken(id, null) so existing callers keep working with the unified plaintext format. decryptActionId delegates to decryptActionToken and returns just the action id. A small fallback in parseTokenPlaintext accepts pre-upgrade plain-string plaintexts as { actionId, bound: null } so tokens issued before this change are still valid during a rolling deploy.

packages/react-server/server/action-register.mjs updates createServerRefBind so the cached $$id getter returns encryptActionToken(fullId, accumulatedBound) rather than encryptActionId(fullId). The bound array is plaintext on the function (needed for Function.prototype.bind invocation and for progressive-enhancement form rendering) but only the encrypted token form goes onto the wire. The unbound registerServerReference path still uses encryptActionId, which now produces a token whose plaintext is [fullId, null] — same shape, no special case at decrypt time.

packages/react-server/server/render-rsc.jsx does three things. It exposes a resolveServerReference on the runtime's moduleResolver that returns { id: ref.$$id, bound: null } for every server reference, so the flight serializer skips its plaintext-bound fallback. The header-based action dispatcher and the progressive-enhancement form-field dispatcher both call decryptActionToken instead of decryptActionId, recover any token-encoded bound, and prepend it to the runtime args before invoking the action. The decodeReply wrapper passes a decryptServerReferenceId hook into @lazarv/rsc so the callback-arg case (a bound server reference passed as a value to another server function call) decrypts the inner token and prepends its bound at bind time.

packages/rsc/server/shared.mjs and packages/rsc/server/reply-decoder.mjs add the host-supplied hooks. The flight serializer now honors metadata.bound from resolveServerReference when explicitly provided, falling back to value.$$bound only when the resolver doesn't speak. The reply decoder accepts a decryptServerReferenceId option that, when present, transforms the $h chunk's id into { actionId, bound }; the recovered bound is prepended to any wire-supplied bound array before binding. Both branches stay no-op by default — @lazarv/rsc itself has no opinion about token formats and remains runtime-agnostic.

Migration

Backward compatible. The encryption key resolution chain (serverFunctions.secret / secretFile, env vars, previousSecrets / previousSecretFiles) is unchanged and covers both action identity and bound captures under one key. Tokens issued by an older runtime version that's still serving traffic during a rolling deploy decode cleanly via the legacy plain-string fallback in parseTokenPlaintext. There are no new configuration flags and no transitional period to manage.

Tests

test/__test__/action-crypto.spec.mjs covers token roundtrip across primitive and structured bound values, tamper detection (single-byte flip, truncation, non-base64), key rotation (sign under previous, decrypt under primary or rotation), legacy plain-string plaintext compatibility, encryptActionId / decryptActionId thin-wrapper semantics, and a full typed-value matrix asserting Date, BigInt, Map, Set, RegExp, URL, URLSearchParams, and typed arrays each survive the encrypt/decrypt round-trip with both instanceof and value equality. A nested-mix case asserts that typed values inside structured bound (a Date inside an object inside an array, a Map<string, Object[]>, etc.) all round-trip together.

packages/rsc/__tests__/flight-bound-args-integrity.test.mjs covers the protocol layer: that a resolver returning bound: null overrides $$bound and emits no plaintext bound on the wire, that the unbound case carries bound: null end-to-end, that consumers without a resolver still get the legacy serialization (back-compat for plain @lazarv/rsc users), and that the $h decoder hook is invoked on token-encoded ids in the callback-arg case and prepends recovered bound to any wire-supplied bound.

The existing test/__test__/use-inline.spec.mjs ("use server inline with captured variables") exercises the full pipeline — page render → flight stream → client decode → callServer → decrypt → dispatch — with closures capturing render-time data. It is the load-bearing E2E for this change and continues to pass without modification.

Docs

docs/src/pages/en/(pages)/guide/server-functions.mdx and the Japanese mirror gain a Security section covering action identity and bound captures, key resolution order, key rotation pattern, semantics of client-side .bind() extensions (treated as runtime args, not as new captures), and the one known limitation: bound captures whose values are File or Blob carry the slot reference in the token but not the binary content, which is rare in practice but worth flagging. The Japanese file also gets <Link name> anchors that match the EN convention and a closing fence for a previously dangling code block.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
react-server-docs aeba5fa May 08 2026, 07:04 PM

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

⚡ Flight Protocol Benchmark

Commit: 08bdcfa

Serialization (renderToReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 227.9K 34.8K 🟢 +554.2%
react: shallow wide (1000) 2.3K 296 🟢 +668.4%
react: deep nested (100) 17.1K 5.4K 🟢 +217.1%
react: product list (50) 6.7K 1.9K 🟢 +250.3%
react: large table (500x10) 279 97 🟢 +187.5%
data: primitives 174.5K 37.7K 🟢 +363.3%
data: large string (100KB) 7.2K 6.4K 🟢 +13.2%
data: nested objects (20) 60.3K 27.6K 🟢 +118.2%
data: large array (10K) 127 126 ⚪ +0.6%
data: Map & Set 12.0K 6.5K 🟢 +84.1%
data: Date/BigInt/Symbol 169.4K 40.3K 🟢 +320.2%
data: typed arrays 38.5K 11.4K 🟢 +239.1%
data: mixed payload 9.3K 3.9K 🟢 +141.3%

Prerender (prerender)

Scenario @lazarv/rsc ops/s mean
react: minimal element 227.3K 4.4 µs
react: shallow wide (1000) 2.0K 501.6 µs
react: deep nested (100) 15.8K 63.5 µs
react: product list (50) 6.2K 162.2 µs
react: large table (500x10) 267 3.74 ms
data: primitives 189.2K 5.3 µs
data: large string (100KB) 668 1.50 ms
data: nested objects (20) 61.3K 16.3 µs
data: large array (10K) 121 8.27 ms
data: Map & Set 11.9K 83.9 µs
data: Date/BigInt/Symbol 184.6K 5.4 µs
data: typed arrays 663 1.51 ms
data: mixed payload 8.2K 122.5 µs

Deserialization (createFromReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 166.6K 146.5K 🟢 +13.7%
react: shallow wide (1000) 22.1K 2.1K 🟢 +963.4%
react: deep nested (100) 97.0K 19.2K 🟢 +405.8%
react: product list (50) 49.6K 14.7K 🟢 +238.1%
react: large table (500x10) 3.8K 2.0K 🟢 +89.7%
data: primitives 134.9K 132.0K 🟢 +2.2%
data: large string (100KB) 39.3K 33.5K 🟢 +17.5%
data: nested objects (20) 79.5K 68.8K 🟢 +15.6%
data: large array (10K) 270 238 🟢 +13.5%
data: Map & Set 16.4K 14.8K 🟢 +11.0%
data: Date/BigInt/Symbol 139.3K 120.9K 🟢 +15.2%
data: typed arrays 54.6K 43.6K 🟢 +25.2%
data: mixed payload 24.1K 14.9K 🟢 +62.2%

Roundtrip (serialize + deserialize)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 119.6K 29.0K 🟢 +312.3%
react: shallow wide (1000) 1.8K 303 🟢 +480.8%
react: deep nested (100) 15.0K 4.7K 🟢 +219.3%
react: product list (50) 5.7K 1.7K 🟢 +237.6%
react: large table (500x10) 252 89 🟢 +181.9%
data: primitives 94.2K 39.0K 🟢 +141.3%
data: large string (100KB) 6.6K 6.8K 🔴 -2.1%
data: nested objects (20) 37.6K 22.4K 🟢 +67.8%
data: large array (10K) 87 79 🟢 +10.5%
data: Map & Set 6.9K 4.4K 🟢 +55.7%
data: Date/BigInt/Symbol 93.8K 31.2K 🟢 +200.8%
data: typed arrays 28.7K 12.1K 🟢 +136.2%
data: mixed payload 6.8K 3.3K 🟢 +104.5%
Legend & methodology

Indicators: 🟢 > 1% faster | 🔴 > 1% slower | ⚪ within noise margin

vs webpack: compares @lazarv/rsc against react-server-dom-webpack within the same run.
vs baseline: compares @lazarv/rsc against the previous main branch run.

Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup.

Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 8, 2026

Codecov Report

❌ Patch coverage is 65.45455% with 19 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@96c56e7). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/rsc/server/shared.mjs 38.46% 16 Missing ⚠️
packages/rsc/server/reply-decoder.mjs 89.65% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #421   +/-   ##
=======================================
  Coverage        ?   91.85%           
=======================================
  Files           ?        3           
  Lines           ?     3622           
  Branches        ?     1195           
=======================================
  Hits            ?     3327           
  Misses          ?      295           
  Partials        ?        0           
Flag Coverage Δ
rsc 91.85% <65.45%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

⚡ Benchmark Results

PR aeba5fa main 96c56e7
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1227 🔴 -4.5% 40.1 ms 🔴 +4.8% 81 ms 0.8 MB/s
small 1255 🔴 -4.2% 39.18 ms 🔴 +4.6% 70 ms 1.2 MB/s
medium 383 🔴 -2.4% 128.4 ms 🔴 +2.2% 201 ms 5.6 MB/s
large 46 🔴 -3.5% 1021.53 ms ⚪ +0.9% 2019 ms 4.6 MB/s
deep 857 🔴 -7.0% 57.6 ms 🔴 +7.5% 104 ms 2.9 MB/s
wide 66 🔴 -7.6% 715.95 ms 🔴 +7.1% 1276 ms 3.6 MB/s
cached 3331 🟢 +5.3% 14.49 ms 🟢 -5.0% 29 ms 48.9 MB/s
client-min 453 ⚪ -0.6% 109.19 ms ⚪ +0.8% 180 ms 1.9 MB/s
client-small 446 🔴 -9.0% 110.69 ms 🔴 +9.6% 162 ms 2.1 MB/s
client-med 345 🔴 -1.8% 143 ms 🔴 +1.8% 217 ms 6.3 MB/s
client-large 78 🔴 -5.7% 620.55 ms 🔴 +6.7% 1043 ms 8.1 MB/s
client-deep 434 🔴 -5.4% 114.11 ms 🔴 +5.7% 163 ms 3.0 MB/s
client-wide 133 🔴 -7.8% 365.68 ms 🔴 +7.3% 626 ms 7.8 MB/s
rsc-client-large 1049 🔴 -7.8% 46.99 ms 🔴 +8.5% 70 ms 2.7 MB/s
rsc-client-wide 1066 🔴 -7.3% 46.23 ms 🔴 +8.0% 64 ms 2.7 MB/s
static-json 6610 🔴 -15.0% 7.08 ms 🔴 +20.2% 17 ms 2.8 MB/s
static-js 6706 🔴 -3.1% 6.95 ms 🔴 +6.1% 17 ms 8.4 MB/s
404-miss 4715 🔴 -2.2% 10.02 ms 🔴 +3.4% 23 ms 0.6 MB/s
hybrid-min 445 🔴 -7.0% 110.76 ms 🔴 +7.0% 176 ms 2.1 MB/s
hybrid-small 440 🔴 -5.2% 111.94 ms 🔴 +5.4% 173 ms 2.6 MB/s
hybrid-medium 241 🔴 -2.5% 205.75 ms 🔴 +2.1% 291 ms 10.2 MB/s
hybrid-large 41 ⚪ 0.0% 1146.61 ms ⚪ +0.8% 2422 ms 13.2 MB/s
hybrid-deep 357 🔴 -6.1% 138.11 ms 🔴 +6.2% 202 ms 4.9 MB/s
hybrid-wide 60 🔴 -2.6% 801.31 ms 🔴 +2.0% 1412 ms 11.8 MB/s
hybrid-cached 2933 🔴 -1.1% 16.48 ms 🔴 +1.3% 32 ms 124.6 MB/s
hybrid-client-min 464 🔴 -6.2% 106.64 ms 🔴 +6.9% 155 ms 2.0 MB/s
hybrid-client-small 471 🔴 -5.3% 105.15 ms 🔴 +5.5% 162 ms 2.2 MB/s
hybrid-client-medium 353 🔴 -4.1% 139.99 ms 🔴 +4.4% 207 ms 6.5 MB/s
hybrid-client-large 77 🔴 -8.4% 630.36 ms 🔴 +11.1% 1369 ms 8.1 MB/s
hybrid-client-deep 432 🔴 -6.2% 114.01 ms 🔴 +6.6% 187 ms 3.1 MB/s
hybrid-client-wide 131 🔴 -8.4% 374.9 ms 🔴 +9.3% 708 ms 7.7 MB/s
Legend

🟢 > 1% improvement | 🔴 > 1% regression | ⚪ within noise margin

Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple routes are more meaningful than any single number.

@lazarv lazarv merged commit 64ce19d into main May 8, 2026
117 of 120 checks passed
@lazarv lazarv deleted the feat/server-function-encrypted-bound-args branch May 8, 2026 19:45
lazarv added a commit that referenced this pull request May 9, 2026
## Summary

This PR closes the third and final piece of the server-function security
series. PR #421 made the action token tamper-evident; PR #422 added the
kill-switch for apps that don't use server functions at all. What was
still missing — and what this lands — is a way to declare *what shape*
an action expects from the wire, so the runtime can reject malformed
payloads at the protocol layer instead of letting them reach handler
code where intent is lost.

The API is `createFunction`, exported from a new
`@lazarv/react-server/function` subpath. It wraps a `"use server"`
handler with a per-arg parse/validate spec, the bundler forwards that
spec to `registerServerReference`, and the protocol decoder consults it
on every call. Bad inputs are caught during decode and the request fails
with `HTTP 400` and an `x-react-server-action-error: <reason>` header
before any handler code runs. Bare `"use server"` actions without
`createFunction` keep working unchanged — validation is opt-in and
additive.

## The API

The most common shape is the array shorthand:
`createFunction([z.string(), z.number()])(handler)`. The slot index is
the *runtime arg slot* — what the client puts on the wire at position
`i` — not the handler signature param. When you also need pre-validate
parsing, the object form takes both arrays explicitly: `createFunction({
parse: [...], validate: [...] })(handler)`. The no-spec form
`createFunction()(handler)` exists too; it attaches the marker so the
dev-strict warning treats the export as deliberately unvalidated. Bound
captures (closure values from `.bind(...)` or render-time closures) are
explicitly *not* part of the validation contract — they're
integrity-protected by the AEAD action token from #421, not validated as
user inputs.

The full TypeScript story comes for free with this API. The handler's
parameter types are inferred from the schemas via the same
`ValidateSchema<T>` / `InferSchema<T>` machinery the typed router
already uses, so any Standard Schema (Zod, Valibot, ArkType, generic
`.parse()`) works as a slot constraint. Hovering an `addEntry` call site
that was declared with `z.object({ name: z.string() })` shows `(input: {
name: string }) => Promise<…>` — derived directly from the schema, no
manual type annotation. Misuse at the call site is a TypeScript error,
not a runtime surprise.

## Wire-aware helpers

A Standard Schema isn't enough for every Flight wire type. Some
validations need to bound resource consumption before the handler
observes the value (file uploads, byte buffers); some need to wrap an
async source so the bound is enforced as the handler consumes (streams,
async iterables); some need a constructor allowlist that's narrowed in
TypeScript via `instanceof` rather than a string-name lookup (typed
arrays). For each of those cases there's a dedicated wire-aware helper.
`formData(shape, options?)` declares a sub-FormData with declared-key
entries (no prefix scan, an attacker-injected `5_role=admin` is rejected
by default), and inside it `file({ maxBytes, mime })` and `blob(...)`
enforce per-entry size and MIME synchronously against `Blob.size` /
`Blob.type`. `arrayBuffer({ maxBytes })` caps byte length on `$AB`,
`typedArray({ ctor: Float32Array, maxBytes })` does the same for `$AT`
while narrowing the inferred handler type to the exact `Float32Array`
instance. `map({ maxSize, key, value })` and `set({ maxSize, value })`
cap collection size and route inner key/value validation through the
same Standard Schema bridge. `stream({ maxChunks, maxBytes })` covers
both the text (`$r`) and binary (`$b`) Flight stream tags by wrapping
the materialized `ReadableStream` in a `TransformStream` that errors
instead of yielding past the cap. `asyncIterable({ maxYields, value })`
and `iterable(...)` do the same for `$x` and `$X`, with each yielded
value flowing through the inner schema as the handler pulls.
`promise(value)` wraps `$@` so the resolved value runs through the
schema before reaching the handler. There's also a `noop` export — an
identity sentinel that reads as intent at the call site when only some
slots need validation, so users don't have to write sparse-array
literals or bare `undefined`.

## Decoder integration and error semantics

In `@lazarv/rsc`, `registerServerReference` gained an optional fourth
`meta` argument and a paired `lookupServerFunctionMeta` for hosts to
query at decode time. `decodeReply`'s options grew `actionId`,
`resolveServerFunctionMeta`, and `validateArg` hooks; when all three are
present the decoder switches from the legacy whole-tree walk to the new
slot-walk in `walkArgsWithMeta`, which applies parse → validate
slot-by-slot and aborts on the first failure with a new
`DecodeValidationError`. The error carries the failing `argIndex`, the
recovered `actionId`, a coarse `reason` code (`validate_failed`,
`parse_failed`, `unknown_entry`, `max_bytes_exceeded`,
`max_size_exceeded`, `max_chunks_exceeded`, `max_yields_exceeded`,
`mime_not_allowed`, `wire_shape_mismatch`, `missing_entry`,
`duplicate_entry`, `custom_validate_failed`, `max_bound_args_exceeded`),
and the underlying schema diagnostic in `original`. The legacy `$h` path
in `shared.mjs` got a parallel structural defense-in-depth pass: when an
action has registered meta and the encrypted token already delivered
bound captures, any non-empty wire-supplied `parsed.bound` is rejected
as a wire-shape mismatch — the trusted channel for closure captures is
the AEAD-protected token, not the wire's `bound` field.

## Dispatcher and dev guardrail

In `render-rsc.jsx`, the action-call dispatch now pre-resolves the
action id (header decrypt or `$ACTION_ID_*` form-field scan) *before*
`decodeReply`, then preloads the action's source module via
`requireModule` so the meta registry is populated by the time the
slot-walk asks for it. Without this preload, every action's first
invocation would silently skip validation because the registry is filled
by the module's top-level `registerServerReference` calls, which run
only after import. Validation failures map to HTTP 400 with the reason
in `x-react-server-action-error`. Schema diagnostics deliberately don't
travel to the client — they can leak expected-shape details that aid
attackers — but they're written to the server log via `logger.warn` for
operator visibility. There's also a dev-only guardrail: each unwrapped
`"use server"` action logs a one-time warning the first time it's
called, naming the action in the same `<modulePath>#<exportName>` form
the registry keys on, in a styled message that distinguishes file paths
(gray italic) from JS code (magenta) and import specifiers (cyan). Set
`config.serverFunctions.strict = false` to silence it during incremental
migrations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants