Files
cloudflare__vinext/tests/app-server-action-execution.test.ts
T
Nathan Nguyen 09059e7dec fix(actions): cap decoded server action arguments (#1107)
Fetch server actions accepted every value returned by decodeReply and spread that array into action.apply. A hostile action payload could force an oversized argument list and hit the same stack-overflow class Next.js guards against.

Add the Next.js-compatible 1000 argument limit immediately before invoking the action, preserving the existing action error packaging path. Cover the regression with a focused fetch-action test that proves the action is not called for 1001 decoded arguments.
2026-05-06 17:37:29 +01:00

790 lines
25 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
handleServerActionRscRequest,
handleProgressiveServerActionRequest,
isProgressiveServerActionRequest,
type HandleServerActionRscRequestOptions,
readActionBodyWithLimit,
readActionFormDataWithLimit,
type HandleProgressiveServerActionRequestOptions,
} from "../packages/vinext/src/server/app-server-action-execution.js";
import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js";
type TestRoute = {
id: string;
params: readonly string[];
pattern: string;
};
type TestInterceptOptions = {
slot: string;
};
type TestTemporaryReferences = {
marker: string;
};
type TestActionModel = {
returnValue: unknown;
root: string;
};
function createMultipartRequest(headers?: HeadersInit): Request {
const requestHeaders = new Headers({
"content-type": "multipart/form-data; boundary=vinext",
host: "example.com",
origin: "https://example.com",
});
if (headers) {
for (const [key, value] of new Headers(headers)) {
requestHeaders.set(key, value);
}
}
return new Request("https://example.com/action-source", {
method: "POST",
headers: requestHeaders,
});
}
function createMultipartBodyRequest(body: FormData): Request {
return new Request("https://example.com/action-source", {
method: "POST",
body,
headers: {
host: "example.com",
origin: "https://example.com",
},
});
}
function createStreamBodyRequest(body: string, headers?: HeadersInit): Request {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
const init: RequestInit & { duplex: "half" } = {
method: "POST",
body: stream,
duplex: "half",
headers,
};
return new Request("https://example.com/action", init);
}
function createOptions(
overrides: Partial<HandleProgressiveServerActionRequestOptions> = {},
): HandleProgressiveServerActionRequestOptions {
return {
actionId: null,
allowedOrigins: [],
cleanPathname: "/action-source",
clearRequestContext() {},
contentType: "multipart/form-data; boundary=vinext",
async decodeAction() {
return null;
},
getAndClearPendingCookies() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
maxActionBodySize: 1024,
middlewareHeaders: null,
async readFormDataWithLimit() {
return new FormData();
},
reportRequestError() {},
request: createMultipartRequest(),
setHeadersAccessPhase() {
return "render";
},
...overrides,
};
}
function createFetchActionRequest(headers?: HeadersInit): Request {
const requestHeaders = new Headers({
"content-type": "text/plain;charset=UTF-8",
host: "example.com",
origin: "https://example.com",
"x-rsc-action": "action-id",
});
if (headers) {
for (const [key, value] of new Headers(headers)) {
requestHeaders.set(key, value);
}
}
return new Request("https://example.com/dashboard?tab=activity", {
body: "encoded-flight-body",
method: "POST",
headers: requestHeaders,
});
}
function createRscOptions(
overrides: Partial<
HandleServerActionRscRequestOptions<
string,
TestRoute,
TestInterceptOptions,
TestTemporaryReferences
>
> = {},
): HandleServerActionRscRequestOptions<
string,
TestRoute,
TestInterceptOptions,
TestTemporaryReferences
> {
const route: TestRoute = { id: "dashboard", params: [], pattern: "/dashboard" };
return {
actionId: "action-id",
allowedOrigins: [],
buildPageElement({ route: matchedRoute, params, interceptOpts }) {
return `${matchedRoute.id}:${JSON.stringify(params)}:${interceptOpts?.slot ?? "none"}`;
},
cleanPathname: "/dashboard",
clearRequestContext() {},
contentType: "text/plain;charset=UTF-8",
createNotFoundElement(routeId) {
return `not-found:${routeId}`;
},
createPayloadRouteId(pathname, interceptionContext) {
return `${pathname}:${interceptionContext ?? "none"}`;
},
createRscOnErrorHandler(_request, pathname, pattern) {
return () => `${pathname}:${pattern}`;
},
createTemporaryReferenceSet() {
return { marker: "refs" };
},
decodeReply() {
return Promise.resolve([]);
},
findIntercept() {
return null;
},
getAndClearPendingCookies() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
getRouteParamNames(matchedRoute) {
return matchedRoute.params;
},
getSourceRoute() {
return undefined;
},
isRscRequest: true,
loadServerAction() {
return Promise.resolve(() => "action-result");
},
matchRoute() {
return { params: {}, route };
},
maxActionBodySize: 1024,
middlewareHeaders: null,
middlewareStatus: null,
mountedSlotsHeader: null,
readBodyWithLimit() {
return Promise.resolve("encoded-flight-body");
},
readFormDataWithLimit() {
return Promise.resolve(new FormData());
},
renderToReadableStream(model) {
return new Response(JSON.stringify(model)).body;
},
reportRequestError() {},
request: createFetchActionRequest(),
sanitizeErrorForClient(error) {
return error;
},
searchParams: new URLSearchParams("tab=activity"),
setHeadersAccessPhase() {
return "render";
},
setNavigationContext() {},
toInterceptOpts(intercept) {
return { slot: intercept.slotKey };
},
...overrides,
};
}
describe("app server action execution helpers", () => {
it("reads streamed action text bodies and enforces the byte limit", async () => {
const validRequest = new Request("https://example.com/action", {
method: "POST",
body: "hello",
});
await expect(readActionBodyWithLimit(validRequest, 5)).resolves.toBe("hello");
const oversizedRequest = createStreamBodyRequest("hello!");
await expect(readActionBodyWithLimit(oversizedRequest, 5)).rejects.toThrow(
"Request body too large",
);
});
it("reads multipart action form data and enforces the streamed byte limit", async () => {
const body = new FormData();
body.set("field", "value");
const validRequest = new Request("https://example.com/action", {
method: "POST",
body,
});
const formData = await readActionFormDataWithLimit(validRequest, 1024);
expect(formData.get("field")).toBe("value");
const oversizedRequest = createStreamBodyRequest("x".repeat(64), {
"content-type": validRequest.headers.get("content-type") ?? "",
});
await expect(readActionFormDataWithLimit(oversizedRequest, 16)).rejects.toThrow(
"Request body too large",
);
});
it("identifies progressive multipart server action submissions", () => {
expect(
isProgressiveServerActionRequest(
{ method: "post" },
"multipart/form-data; boundary=vinext",
null,
),
).toBe(true);
expect(
isProgressiveServerActionRequest(
{ method: "POST" },
"multipart/form-data; boundary=vinext",
"action-id",
),
).toBe(false);
expect(isProgressiveServerActionRequest({ method: "GET" }, "multipart/form-data", null)).toBe(
false,
);
expect(isProgressiveServerActionRequest({ method: "POST" }, "text/plain", null)).toBe(false);
});
it("returns null for non-progressive action requests", async () => {
const response = await handleProgressiveServerActionRequest(
createOptions({
actionId: "action-id",
decodeAction: vi.fn(),
}),
);
expect(response).toBeNull();
});
it("returns null for non-action multipart posts without consuming the original body", async () => {
const formData = new FormData();
formData.set("field", "value");
const request = createMultipartBodyRequest(formData);
const response = await handleProgressiveServerActionRequest(
createOptions({
contentType: request.headers.get("content-type") ?? "",
async decodeAction() {
return null;
},
readFormDataWithLimit(readRequest) {
return readRequest.formData();
},
request,
}),
);
expect(response).toBeNull();
expect((await request.formData()).get("field")).toBe("value");
});
it("enforces content-length and stream body limits", async () => {
const clearContext = vi.fn();
const lengthResponse = await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
maxActionBodySize: 10,
request: createMultipartRequest({ "content-length": "11" }),
}),
);
expect(lengthResponse?.status).toBe(413);
expect(await lengthResponse?.text()).toBe("Payload Too Large");
expect(clearContext).toHaveBeenCalledTimes(1);
const streamLimitResponse = await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
readFormDataWithLimit() {
throw new Error("Request body too large");
},
}),
);
expect(streamLimitResponse?.status).toBe(413);
expect(await streamLimitResponse?.text()).toBe("Payload Too Large");
expect(clearContext).toHaveBeenCalledTimes(2);
});
it("rejects malformed action payloads before decoding the action", async () => {
const formData = new FormData();
formData.set("0", '"$Q1"');
const decodeAction = vi.fn();
const response = await handleProgressiveServerActionRequest(
createOptions({
decodeAction,
readFormDataWithLimit() {
return Promise.resolve(formData);
},
}),
);
expect(response?.status).toBe(400);
expect(await response?.text()).toBe("Invalid server action payload");
expect(decodeAction).not.toHaveBeenCalled();
});
it("executes decoded form actions and converts redirects into 303 responses", async () => {
const phaseCalls: string[] = [];
const clearContext = vi.fn();
const formData = new FormData();
formData.set("$ACTION_ID_test", "");
const response = await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
async decodeAction(body) {
expect(body).toBe(formData);
return () => {
throw { digest: "NEXT_REDIRECT;replace;%2Fresult%3Fok%3D1;307" };
};
},
getAndClearPendingCookies() {
return ["session=1; Path=/"];
},
getDraftModeCookieHeader() {
return "draft=1; Path=/";
},
middlewareHeaders: new Headers([["x-middleware", "present"]]),
readFormDataWithLimit() {
return Promise.resolve(formData);
},
setHeadersAccessPhase(phase) {
phaseCalls.push(phase);
return "render";
},
}),
);
expect(response?.status).toBe(303);
expect(response?.headers.get("location")).toBe("https://example.com/result?ok=1");
expect(response?.headers.get("x-middleware")).toBe("present");
expect(response?.headers.getSetCookie()).toEqual(["session=1; Path=/", "draft=1; Path=/"]);
expect(phaseCalls).toEqual(["action", "render"]);
expect(clearContext).toHaveBeenCalledTimes(1);
});
it("falls through after successful non-redirect actions without consuming the original body", async () => {
const formData = new FormData();
formData.set("$ACTION_ID_test", "");
formData.set("field", "value");
const request = createMultipartBodyRequest(formData);
let actionRan = false;
const response = await handleProgressiveServerActionRequest(
createOptions({
contentType: request.headers.get("content-type") ?? "",
async decodeAction() {
return () => {
actionRan = true;
};
},
readFormDataWithLimit(readRequest) {
return readRequest.formData();
},
request,
}),
);
expect(response).toBeNull();
expect(actionRan).toBe(true);
expect((await request.formData()).get("field")).toBe("value");
});
it("maps action HTTP fallback errors to status responses", async () => {
for (const [digest, statusCode] of [
["NEXT_NOT_FOUND", 404],
["NEXT_HTTP_ERROR_FALLBACK;403", 403],
]) {
const clearContext = vi.fn();
const reportedErrors: Error[] = [];
const response = await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
async decodeAction() {
return () => {
throw { digest };
};
},
reportRequestError(error) {
reportedErrors.push(error);
},
}),
);
expect(response?.status).toBe(statusCode);
expect(reportedErrors).toEqual([]);
expect(clearContext).toHaveBeenCalledTimes(1);
}
});
it("reports action execution failures and clears pending cookies", async () => {
const reportedErrors: Error[] = [];
const clearedCookies = vi.fn(() => ["session=1; Path=/"]);
const clearContext = vi.fn();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const response = await handleProgressiveServerActionRequest(
createOptions({
cleanPathname: "/action-source",
clearRequestContext: clearContext,
async decodeAction() {
return () => {
throw new Error("boom");
};
},
getAndClearPendingCookies: clearedCookies,
reportRequestError(error) {
reportedErrors.push(error);
},
}),
);
expect(response?.status).toBe(500);
expect(await response?.text()).toBe("Server action failed: boom");
expect(reportedErrors.map((error) => error.message)).toEqual(["boom"]);
expect(clearedCookies).toHaveBeenCalledTimes(1);
expect(clearContext).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
// Ported from Next.js: test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
it("returns the action-not-found response for progressive action decode misses", async () => {
const reportedErrors: Error[] = [];
const clearContext = vi.fn();
const response = await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
async decodeAction() {
throw new Error(
"Failed to find Server Action. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
);
},
reportRequestError(error) {
reportedErrors.push(error);
},
}),
);
expect(response?.status).toBe(404);
expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1");
expect(await response?.text()).toBe("Server action not found.");
expect(reportedErrors).toEqual([]);
expect(clearContext).toHaveBeenCalledTimes(1);
});
it("returns action-not-found for progressive decode misses that include an action id", async () => {
const response = await handleProgressiveServerActionRequest(
createOptions({
async decodeAction() {
throw new Error(
'Failed to find Server Action "stale-action-id". This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action',
);
},
}),
);
expect(response?.status).toBe(404);
expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1");
});
it("returns null for non-fetch RSC action requests", async () => {
const response = await handleServerActionRscRequest(
createRscOptions({
actionId: null,
loadServerAction: vi.fn(),
}),
);
expect(response).toBeNull();
});
it("executes fetch actions and returns a rerendered RSC payload", async () => {
const phaseCalls: string[] = [];
const navigationContexts: Array<{
params: Record<string, string | string[]>;
pathname: string;
}> = [];
const temporaryReferences = { marker: "test-refs" };
let renderedModel: TestActionModel | null = null;
const response = await handleServerActionRscRequest(
createRscOptions({
createTemporaryReferenceSet() {
return temporaryReferences;
},
decodeReply(body, options) {
expect(body).toBe("encoded-flight-body");
expect(options.temporaryReferences).toBe(temporaryReferences);
return Promise.resolve(["first", "second"]);
},
getAndClearPendingCookies() {
return ["action=1; Path=/"];
},
getDraftModeCookieHeader() {
return "draft=1; Path=/";
},
loadServerAction(actionId) {
expect(actionId).toBe("action-id");
return Promise.resolve(
(first: unknown, second: unknown) => `${String(first)}:${String(second)}`,
);
},
middlewareHeaders: new Headers([["x-middleware", "present"]]),
renderToReadableStream(model, options) {
renderedModel = model;
expect(options.temporaryReferences).toBe(temporaryReferences);
return new Response("flight-payload").body;
},
setHeadersAccessPhase(phase) {
phaseCalls.push(phase);
return "render";
},
setNavigationContext(context) {
navigationContexts.push({
params: context.params,
pathname: context.pathname,
});
},
}),
);
expect(response?.status).toBe(200);
expect(response?.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
expect(response?.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
expect(response?.headers.get("x-middleware")).toBe("present");
expect(response?.headers.getSetCookie()).toEqual(["action=1; Path=/", "draft=1; Path=/"]);
expect(await response?.text()).toBe("flight-payload");
expect(renderedModel).toEqual({
root: "dashboard:{}:none",
returnValue: { ok: true, data: "first:second" },
});
expect(phaseCalls).toEqual(["action", "render"]);
expect(navigationContexts).toEqual([{ params: {}, pathname: "/dashboard" }]);
});
// Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts
it("rejects fetch actions with too many decoded arguments before invoking the action", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const action = vi.fn();
let renderedModel: TestActionModel | null = null;
const response = await handleServerActionRscRequest(
createRscOptions({
decodeReply() {
return Array.from({ length: 1001 }, (_, index) => index);
},
loadServerAction() {
return Promise.resolve(action);
},
renderToReadableStream(model) {
renderedModel = model;
return new Response("too-many-args-flight").body;
},
sanitizeErrorForClient(error) {
return error instanceof Error ? error.message : String(error);
},
}),
);
expect(response?.status).toBe(200);
expect(await response?.text()).toBe("too-many-args-flight");
expect(action).not.toHaveBeenCalled();
expect(renderedModel).toEqual({
root: "dashboard:{}:none",
returnValue: {
ok: false,
data: "Server Action arguments list is too long (1001). Maximum allowed is 1000.",
},
});
} finally {
errorSpy.mockRestore();
}
});
it("rejects malformed fetch-action payloads before decodeReply", async () => {
const decodeReply = vi.fn();
const response = await handleServerActionRscRequest(
createRscOptions({
readBodyWithLimit() {
return Promise.resolve('{"0":"$Q1"}');
},
decodeReply,
}),
);
expect(response?.status).toBe(400);
expect(await response?.text()).toBe("Invalid server action payload");
expect(decodeReply).not.toHaveBeenCalled();
});
// Ported from Next.js: test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
it("returns the Next.js action-not-found response for stale fetch action ids", async () => {
const decodeReply = vi.fn();
const renderToReadableStream = vi.fn();
const reportRequestError = vi.fn();
const clearRequestContext = vi.fn();
const response = await handleServerActionRscRequest(
createRscOptions({
actionId: "stale-action-id",
clearRequestContext,
decodeReply,
loadServerAction() {
return Promise.reject(new Error("[vite-rsc] invalid server reference 'stale-action-id'"));
},
renderToReadableStream,
reportRequestError,
}),
);
expect(response?.status).toBe(404);
expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1");
expect(response?.headers.get("content-type")).toBe("text/plain");
expect(await response?.text()).toBe("Server action not found.");
expect(decodeReply).not.toHaveBeenCalled();
expect(renderToReadableStream).not.toHaveBeenCalled();
expect(reportRequestError).not.toHaveBeenCalled();
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("returns action-not-found when a server action export is missing", async () => {
const decodeReply = vi.fn();
const response = await handleServerActionRscRequest(
createRscOptions({
decodeReply,
loadServerAction() {
return Promise.resolve(undefined);
},
}),
);
expect(response?.status).toBe(404);
expect(response?.headers.get("x-nextjs-action-not-found")).toBe("1");
expect(await response?.text()).toBe("Server action not found.");
expect(decodeReply).not.toHaveBeenCalled();
});
it("keeps unrelated server action loader failures on the generic error path", async () => {
const reportedErrors: Error[] = [];
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const response = await handleServerActionRscRequest(
createRscOptions({
loadServerAction() {
return Promise.reject(new Error("module graph crashed"));
},
reportRequestError(error) {
reportedErrors.push(error);
},
}),
);
expect(response?.status).toBe(500);
expect(await response?.text()).toBe("Server action failed: module graph crashed");
expect(reportedErrors.map((error) => error.message)).toEqual(["module graph crashed"]);
errorSpy.mockRestore();
});
it("encodes fetch-action redirects as RSC control headers", async () => {
const clearContext = vi.fn();
const renderToReadableStream = vi.fn();
const response = await handleServerActionRscRequest(
createRscOptions({
clearRequestContext: clearContext,
getAndClearPendingCookies() {
return ["action=1; Path=/"];
},
loadServerAction() {
return Promise.resolve(() => {
throw { digest: "NEXT_REDIRECT;;%2Ftarget%3Fok%3D1;308" };
});
},
middlewareHeaders: new Headers([["x-middleware", "present"]]),
renderToReadableStream,
}),
);
expect(response?.status).toBe(200);
expect(response?.headers.get("x-action-redirect")).toBe("/target?ok=1");
expect(response?.headers.get("x-action-redirect-type")).toBe("push");
expect(response?.headers.get("x-action-redirect-status")).toBe("308");
expect(response?.headers.get("x-middleware")).toBe("present");
expect(response?.headers.getSetCookie()).toEqual(["action=1; Path=/"]);
expect(await response?.text()).toBe("");
expect(clearContext).toHaveBeenCalledTimes(1);
expect(renderToReadableStream).not.toHaveBeenCalled();
});
it("packages access fallback digests into the action return payload", async () => {
let renderedModel: TestActionModel | null = null;
const notFoundError = { digest: "NEXT_NOT_FOUND" };
const response = await handleServerActionRscRequest(
createRscOptions({
loadServerAction() {
return Promise.resolve(() => {
throw notFoundError;
});
},
renderToReadableStream(model) {
renderedModel = model;
return new Response("fallback-flight").body;
},
}),
);
expect(response?.status).toBe(200);
expect(await response?.text()).toBe("fallback-flight");
expect(renderedModel).toEqual({
root: "dashboard:{}:none",
returnValue: { ok: false, data: notFoundError },
});
});
});