Commit 64ce19d
authored
feat: server function encrypted bound args (#421)
## 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.1 parent 96c56e7 commit 64ce19d
9 files changed
Lines changed: 1191 additions & 72 deletions
File tree
- docs/src/pages
- en/(pages)/guide
- ja/(pages)/guide
- packages
- react-server/server
- rsc
- __tests__
- server
- test/__test__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
340 | 340 | | |
341 | 341 | | |
342 | 342 | | |
343 | | - | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
202 | | - | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
9 | 12 | | |
10 | 13 | | |
11 | 14 | | |
| |||
193 | 196 | | |
194 | 197 | | |
195 | 198 | | |
196 | | - | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
197 | 216 | | |
198 | | - | |
199 | | - | |
| 217 | + | |
200 | 218 | | |
201 | | - | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
202 | 227 | | |
203 | 228 | | |
204 | | - | |
| 229 | + | |
205 | 230 | | |
206 | 231 | | |
207 | 232 | | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
208 | 246 | | |
209 | 247 | | |
210 | | - | |
| 248 | + | |
211 | 249 | | |
212 | 250 | | |
213 | 251 | | |
| |||
216 | 254 | | |
217 | 255 | | |
218 | 256 | | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
219 | 270 | | |
220 | 271 | | |
221 | 272 | | |
| |||
248 | 299 | | |
249 | 300 | | |
250 | 301 | | |
251 | | - | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
252 | 305 | | |
253 | | - | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
254 | 369 | | |
255 | 370 | | |
256 | | - | |
| 371 | + | |
257 | 372 | | |
258 | | - | |
| 373 | + | |
259 | 374 | | |
260 | 375 | | |
261 | | - | |
262 | | - | |
263 | 376 | | |
264 | | - | |
| 377 | + | |
265 | 378 | | |
266 | | - | |
267 | | - | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
268 | 383 | | |
269 | | - | |
270 | 384 | | |
271 | 385 | | |
272 | 386 | | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
273 | 401 | | |
274 | 402 | | |
275 | 403 | | |
| |||
0 commit comments