Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ Or add it to your `package.json` as a file dependency:
}
```

vinext has peer dependencies on `react ^19.2.4`, `react-dom ^19.2.4`, and `vite ^7.0.0 || ^8.0.0`. Then replace `next` with `vinext` in your scripts and run as normal.
vinext has peer dependencies on `react ^19.2.5`, `react-dom ^19.2.5`, `react-server-dom-webpack ^19.2.5`, and `vite ^7.0.0 || ^8.0.0`. Then replace `next` with `vinext` in your scripts and run as normal.

## Contributing

Expand Down
8 changes: 4 additions & 4 deletions packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@
"peerDependencies": {
"@mdx-js/rollup": "^3.0.0",
"@vitejs/plugin-react": "^5.1.4 || ^6.0.0",
"@vitejs/plugin-rsc": "^0.5.21",
"react": ">=19.2.0",
"react-dom": ">=19.2.0",
"react-server-dom-webpack": "^19.2.4",
"@vitejs/plugin-rsc": "^0.5.23",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-server-dom-webpack": "^19.2.5",
"vite": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ async function buildApp() {
: createBuildLogger(vite);

// For App Router: upgrade React if needed for react-server-dom-webpack compatibility.
// Without this, builds with react<19.2.4 produce a Worker that crashes at
// Without this, builds with react<19.2.5 produce a Worker that crashes at
// runtime with "Cannot read properties of undefined (reading 'moduleMap')".
if (isApp) {
const reactUpgrade = getReactUpgradeDeps(process.cwd());
Expand Down
8 changes: 7 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(inst
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(metadataRoutesPath)};` : ""}
import { requestContextFromRequest, normalizeHost, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from ${JSON.stringify(configMatchersPath)};
import { decodePathParams as __decodePathParams } from ${JSON.stringify(normalizePathModulePath)};
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from ${JSON.stringify(requestPipelinePath)};
import { validateCsrfOrigin, validateServerActionPayload, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from ${JSON.stringify(requestPipelinePath)};
import {
isKnownDynamicAppRoute as __isKnownDynamicAppRoute,
} from ${JSON.stringify(appRouteHandlerRuntimePath)};
Expand Down Expand Up @@ -1662,6 +1662,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
throw sizeErr;
}
const payloadResponse = await validateServerActionPayload(body);
if (payloadResponse) {
setHeadersContext(null);
setNavigationContext(null);
return payloadResponse;
}
const temporaryReferences = createTemporaryReferenceSet();
const args = await decodeReply(body, { temporaryReferences });
const action = await loadServerAction(actionId);
Expand Down
8 changes: 4 additions & 4 deletions packages/vinext/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function isDepInstalled(root: string, dep: string): boolean {
* Check if react/react-dom need upgrading for react-server-dom-webpack compatibility.
*
* react-server-dom-webpack versions are pinned to match their React version
* (e.g. rsdw@19.2.4 requires react@^19.2.4). When a project has an older
* (e.g. rsdw@19.2.5 requires react@^19.2.5). When a project has an older
* React (e.g. create-next-app ships react@19.2.3), we need to upgrade
* react/react-dom BEFORE installing rsdw to avoid peer-dep conflicts.
*
Expand All @@ -169,12 +169,12 @@ export function getReactUpgradeDeps(root: string): string[] {
const resolved = req.resolve("react");
const version = findPackageVersion(resolved, "react");
if (!version) return [];
// react-server-dom-webpack@latest currently requires react@^19.2.4
// react-server-dom-webpack@latest currently requires react@^19.2.5
const parts = version.split(".");
const major = parseInt(parts[0], 10);
const minor = parseInt(parts[1], 10);
const patch = parseInt(parts[2], 10);
if (major < 19 || (major === 19 && minor < 2) || (major === 19 && minor === 2 && patch < 4)) {
if (major < 19 || (major === 19 && minor < 2) || (major === 19 && minor === 2 && patch < 5)) {
return ["react@latest", "react-dom@latest"];
}
return [];
Expand Down Expand Up @@ -293,7 +293,7 @@ export async function init(options: InitOptions): Promise<InitResult> {
const missingDeps = neededDeps.filter((dep) => !isDepInstalled(root, dep));

// For App Router: react-server-dom-webpack requires react/react-dom versions
// to match exactly (e.g. rsdw@19.2.4 needs react@^19.2.4). If the installed
// to match exactly (e.g. rsdw@19.2.5 needs react@^19.2.5). If the installed
// React is too old (common with create-next-app), upgrade it first as a
// regular dependency to avoid ERESOLVE peer-dep conflicts.
if (isApp && missingDeps.includes("react-server-dom-webpack")) {
Expand Down
85 changes: 85 additions & 0 deletions packages/vinext/src/server/request-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,91 @@ export function validateCsrfOrigin(
return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } });
}

/**
* Reject malformed Flight container reference graphs in server action payloads.
*
* `@vitejs/plugin-rsc` vendors its own React Flight decoder. Malicious action
* payloads can abuse container references (`$Q`, `$W`, `$i`) to trigger very
* expensive deserialization before the action is even looked up.
*
* Legitimate React-encoded container payloads use separate numeric backing
* fields (e.g. field `1` plus root field `0` containing `"$Q1"`). We reject
* numeric backing-field graphs that contain missing backing fields or cycles.
* Regular user form fields are ignored entirely.
*/
export async function validateServerActionPayload(
body: string | FormData,
): Promise<Response | null> {
const containerRefRe = /"\$([QWi])(\d+)"/g;
const fieldRefs = new Map<string, Set<string>>();

const collectRefs = (fieldKey: string, text: string): void => {
const refs = new Set<string>();
let match: RegExpExecArray | null;
containerRefRe.lastIndex = 0;
while ((match = containerRefRe.exec(text)) !== null) {
refs.add(match[2]);
}
fieldRefs.set(fieldKey, refs);
};

if (typeof body === "string") {
collectRefs("0", body);
} else {
for (const [key, value] of body.entries()) {
if (!/^\d+$/.test(key)) continue;
if (typeof value === "string") {
collectRefs(key, value);
continue;
}
if (typeof value?.text === "function") {
collectRefs(key, await value.text());
}
}
}

if (fieldRefs.size === 0) return null;

const knownFields = new Set(fieldRefs.keys());
for (const refs of fieldRefs.values()) {
for (const ref of refs) {
if (!knownFields.has(ref)) {
return new Response("Invalid server action payload", {
status: 400,
headers: { "Content-Type": "text/plain" },
});
}
}
}

const visited = new Set<string>();
const stack = new Set<string>();

const hasCycle = (node: string): boolean => {
if (stack.has(node)) return true;
if (visited.has(node)) return false;

visited.add(node);
stack.add(node);
for (const ref of fieldRefs.get(node) ?? []) {
if (hasCycle(ref)) return true;
}
stack.delete(node);
return false;
};

for (const node of fieldRefs.keys()) {
if (hasCycle(node)) {
return new Response("Invalid server action payload", {
status: 400,
headers: { "Content-Type": "text/plain" },
});
}
}

return null;
}

/**
* Check if an origin matches any pattern in the allowed origins list.
* Supports wildcard subdomains (e.g. `*.example.com`).
Expand Down
Loading
Loading