Skip to content

fix(actions): authorize server references in fetch action arguments - #3991

Merged
james-elicx merged 3 commits into
mainfrom
fix/fetch-action-argument-references
Oct 19, 2026
Merged

james-elicx merged 3 commits into
mainfrom
fix/fetch-action-argument-references

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Fetch Server Actions (Next-Action header) pass the request body to React's decodeReply(). A $h value in that body becomes a server reference, and plugin-rsc's decodeReply() resolves it through a server manifest that accepts any export of a server-reference module. A crafted body could therefore hand an action a callable argument that is not a Server Action of the current route, for example:

  • a Server Action owned by a different (for example middleware-protected) route, or
  • a page module's default export (any module with an inline "use server" action is a server-reference module).

The header action id was already checked against the action-owner manifest. Nested argument references were not.

Next.js resolves argument references through a server module map scoped to the current page (createServerModuleMap() reads workStore.page), so neither of these decodes there.

Fix

Scope the manifest instead of inspecting the payload. The fetch-action handler now calls React's own decodeReply(body, serverManifest, options) (from plugin-rsc's vendored react-server-dom). The serverManifest it passes wraps plugin-rsc's createServerManifest() in a proxy, and the proxy returns no entry for any id that areServerActionsOwnedByRoute() rejects for the current route. This is the same owner check already used for the header action id and for progressive $ACTION_REF_ bound references.

React looks up every server reference through this manifest, wherever it sits in the payload: nested arguments, bound args, promises, streams and async iterables. A denied id therefore fails to resolve and its module is never loaded. This fails the same way Next.js's page-scoped map does: React throws while decoding and the action returns its 500 error response, or, for a stream or iterable item, only that argument errors when the action reads it.

vinext no longer depends on React's reply wire format. An earlier revision of this PR scanned the raw multipart body, which broke when React 19.3 changed how it stores reply parts.

Tests

  • E2E (production build of the existing app-action-ownership fixture): new /ownership/argument-refs route.
    • Route-owned exported and inline actions still decode as function arguments, directly and as ReadableStream / async-iterable items.
    • None of these decode into a function (500, no argument description):
      • another route's action
      • the page's default export
      • a reference nested in bound args
      • a repeated part that swaps in another route's action after an owned one
      • another route's action as a ReadableStream or async-iterable item
    • Verified failing before the fix (200 instead of 500).
  • Unit (tests/app-server-action-execution.test.ts): owned references resolve through the scoped manifest; a reference from another route fails to resolve and the action never runs.

Known limitations

  • Dev server: vinext has no action-owner manifest in dev (actionOwners is null in serve mode). So in dev, argument references are not route-scoped, and directly invoked actions are not owner-checked or forwarded either. Production enforces ownership through the owner manifest.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR at head 3cc74135547e44ab2201079549259ed0e230cd54. You have an 8-minute time limit.

@pkg-pr-new

pkg-pr-new Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3991

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@3991

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3991

vinext

npm i https://pkg.pr.new/vinext@3991

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3991

commit: a5a4e2f

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

loadServerAction,
validateActionReferences(__argumentActionIds) {
return __areServerActionsOwnedByRoute(
__VINEXT_ACTION_OWNERS(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Development still accepts cross-route action references here. index.ts deliberately supplies actionOwners: null for serve mode, and areServerActionsOwnedByRoute(null, ...) returns true, while areServerReferencesRegistered() only proves that the referenced export is a registered action. Thus a fetch action on route A can still receive a registered action owned only by route B in dev, even though Next's createServerModuleMap() filters against the current workStore.page and this PR rejects the same payload in production. Please provide route-aware ownership data in dev (and cover both fetch and progressive paths), rather than treating registration as ownership.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining for this PR: route ownership is not tracked in dev at all today. actionOwners is null in serve mode, so directly invoked actions are not owner-checked or forwarded in dev either. Adding dev ownership would mean new dev-time module-graph tracking for every action path, which is outside this fix. In dev, this PR still rejects every argument reference that is not a registered Server Action, including non-action exports such as a page's default. Cross-route ownership is enforced in production via the owner manifest. I've listed this under Known limitations in the PR description.

@ask-bonk

ask-bonk Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 inline warning. Targeted unit tests pass: 123/123.

github run

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a5a4e2f against base 7cb03ce using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 154.2 KB 154.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ +0.0%
Dev server cold start vinext 2.86 s 2.84 s ⚫ -0.8%
Dev server cold start (large Pages Router app) vinext 27.48 s 26.97 s 🟢 -1.9%
Production build time vinext 3.62 s 3.60 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 141.9 KB 141.9 KB ⚫ -0.0%
Server bundle size (gzip) vinext 249.5 KB 249.4 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx
james-elicx marked this pull request as ready for review October 14, 2026 15:53
decodeReply() resolves `$h` argument references through the RSC server
manifest, which accepts any export of a server-reference module. A crafted
fetch action body could therefore pass a page's default export or another
route's Server Action into an action as a callable argument, which Next.js
never decodes.

Scan the reply body for server references before decoding, resolving their
metadata the way React does (including async-iterable entries that only
parse inside an iterator result), and reject the request as action-not-found
unless every reference is a Server Action owned by the current route
(production owner manifest) and loads as a registered Server Action (dev).
Apply the same registration check to bound references in progressive
`$ACTION_REF_` submissions.
@james-elicx
james-elicx force-pushed the fix/fetch-action-argument-references branch from 3cc7413 to bdc24e8 Compare October 18, 2026 22:05
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR at head bdc24e8b59e8923e6997872b5fc52a5426c95ec5. You have an 8-minute time limit.

@clanky-the-clanker

clanky-the-clanker Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

✅ Approved — No issues found. The scoped server manifest makes every argument reference fail closed when its id is not a Server Action of the current route, and it reuses the owner check that already guards the header action id.

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

The scoped server manifest does what the pull request describes:

  • Fails closed: the proxy's get trap throws for any string id that areServerActionsOwnedByRoute rejects. React resolves server references by indexing the manifest, so each reference goes through this trap wherever it sits in the payload: nested arguments, bound args, promises, streams and async iterables.
  • No way around it through React's fallback lookup: a denied lookup throws instead of returning undefined. React therefore cannot retry a module#a#b id as module module#a with export b, which would reach a non-action export of a module the route owns.
  • Same owner check as before: the check is the one already used for the header action id and for progressive $ACTION_REF_ bound references, so the fetch and progressive paths behave the same.
  • Dev server: there is no owner manifest in dev, so argument references are not route-scoped there. The pull request description already lists this as a known limitation.

📖 AGENTS.md: ✅ LGTM

The change follows the repository guidelines. The fix is checked against Next.js's page-scoped server module map, and the runtime behaviour lives in a server/* helper that has its own unit tests.


📋 Code Quality: ✅ LGTM

The new logic sits in the typed server/app-server-action-execution.ts helper. The generated entry only wires in createServerManifest, React's own decodeReply, and a thin validateActionReferences closure, which follows the repository's guidance to keep generated entries thin. Unit and production E2E coverage for the new behaviour is thorough.


⚡ Performance: ✅ LGTM

Each action request adds one proxy and one owner check per server-reference lookup. Both run only on the Server Action path, so the common request path is unaffected.


📝 Documentation: ✅ LGTM

The new code comments explain why the manifest is scoped and why a denied lookup throws instead of returning nothing.


Reviewed commit: a5a4e2fc

const entries: Iterable<[string, FormDataEntryValue]> =
typeof body === "string" ? [["0", body]] : body;
for (const [key, value] of entries) {
if (!/^\d+$/.test(key)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actual React stream chunks never have numeric keys: the client encoder appends them under S<streamId> (for example S1), and the server decoder explicitly collects S entries into streamChunks before feeding them to $R/$x. This numeric-only filter therefore skips every real stream entry, so an unauthorized $h nested in a readable stream or async iterable reaches decodeReply() without being checked. The new tests use numeric key 1, which the real decoder does not associate with $R1/$x1; please scan S<id> entries using the React stream framing and update the tests to use the actual wire keys.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f558ee by removing the raw-body scanner. React's own decodeReply() now resolves argument references through a route-scoped server manifest, so stream parts and repeated parts go through the owner check wherever React reads them. The e2e covers S1 stream/async-iterable parts and a repeated part.

if (typeof value !== "string") continue;
const model = tryParseJson(value);
if (model) {
if (isFirstEntry) models.set(key, model.value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The React decoder stores ordinary string parts with Map.set() for every FormData entry, so duplicate keys are last-write-wins. This scanner instead retains the first parsed value in models. An attacker can append safe metadata under key 1 first and unauthorized metadata second: validation resolves $h1 to the safe ID, while decodeReply() resolves it to the final unauthorized export. Please either reject duplicate ordinary parts or mirror the React last-write-wins behavior, with a regression test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f558ee by removing the raw-body scanner. React's own decodeReply() now resolves argument references through a route-scoped server manifest, so stream parts and repeated parts go through the owner check wherever React reads them. The e2e covers S1 stream/async-iterable parts and a repeated part.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 2 new.

Posted 2 inline blocking findings.

github run

…anifest

Replace the raw reply scanner with a route-scoped server manifest passed
to React's decodeReply(), so every server reference React resolves goes
through the action-owner check without vinext mirroring the reply wire
format.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR at head 1f558eec8bd3c5597d92887f330a4d84e4503d84. You have an 8-minute time limit.

// payload carries it, so that module never loads.
const serverManifest = new Proxy(options.createServerManifest(), {
get(target, id, receiver) {
if (typeof id === "string" && !options.validateActionReferences([id])) return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can still be bypassed through React's module-id fallback. For metadata id a#owned#default, React first reads manifest["a#owned#default"] (denied), then retries manifest["a#owned"] while retaining default as the export name. Since a#owned is route-owned, this trap returns its module metadata and React loads that module's default export. I confirmed the vendored decoder performs the two accesses in that order (["abc#owned#default","abc#owned"]). Please fail the denied lookup without allowing fallback (for example, throw from the trap), and add a regression using ${app.actionIds.inlineArgument}#default; the current module#default test does not exercise this path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a5a4e2f: a denied lookup now throws from the manifest trap, so React never retries the module-id fallback. Added a ${app.actionIds.inlineArgument}#default e2e case, which returned 200 on the previous head and is rejected now.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Since last review: 2 resolved, 0 still open, 1 new.

Posted 1 inline blocking finding.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR at head a5a4e2fc8e5aaa3557dc49c5267a30579269e3e3. You have an 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 0 new.

LGTM!

github run

@james-elicx
james-elicx added this pull request to stack #4090 October 18, 2026 23:49
@james-elicx
james-elicx merged commit bf704ae into main Oct 19, 2026
106 checks passed
@james-elicx
james-elicx deleted the fix/fetch-action-argument-references branch October 19, 2026 10:41
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.

1 participant