diff --git a/.changeset/nojs-server-function-crash.md b/.changeset/nojs-server-function-crash.md new file mode 100644 index 000000000..248576783 --- /dev/null +++ b/.changeset/nojs-server-function-crash.md @@ -0,0 +1,7 @@ +--- +"@solidjs/start": patch +--- + +Stop a no-JS server function POST from returning a 500 when the body is not a form. + +A POST to a server function without the client runtime, carrying an empty body or a non-form content type, left a value that is not a `FormData` as the last argument. Building the flash cookie called `.entries()` on it and threw, and the error handler rethrew the same way, so the request failed with a 500. The response is now the normal redirect, and the flash cookie is best effort so it can no longer take down the error path. diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index ac5dedd43..642f746f1 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -337,6 +337,65 @@ describe("seroval stream response headers", () => { }); }); +describe("the no-JS server function handler", () => { + const callNoJS = async (init: RequestInit, fn: (...args: any[]) => unknown) => { + const request = new Request("http://localhost/_server?id=fn", { + method: "POST", + // no X-Server-Instance header, so this is the no-JS form path + ...init, + }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + vi.mocked(getServerFunction).mockReturnValue(fn as never); + const { handleServerFunction } = await import("./handler.ts"); + return handleServerFunction(h3Event as never); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Regression for SS-2026-002: a non-form body left `undefined` as the last + // parsed argument, and building the flash cookie called `.entries()` on it. + it("redirects instead of crashing on a non-form body", async () => { + const response = await callNoJS( + { headers: { "content-type": "text/plain" }, body: "not a form" }, + () => ({ ok: true }), + ); + + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(302); + }); + + it("redirects instead of crashing on an empty body", async () => { + const response = await callNoJS({ body: "" }, () => "a value"); + + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(302); + }); + + it("still echoes submitted form fields in the flash cookie", async () => { + const form = new URLSearchParams({ title: "hello" }); + const response = await callNoJS( + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }, + () => ({ ok: true }), + ); + + const cookie = (response as Response).headers.get("Set-Cookie") ?? ""; + const flash = decodeURIComponent(/flash=([^;]+)/.exec(cookie)?.[1] ?? ""); + expect(flash).toContain("title"); + expect(flash).toContain("hello"); + }); +}); + describe("cross-site request rejection (CSRF)", () => { const call = async (headers: Record, method = "POST") => { const request = new Request("http://localhost/_server?id=fn", { method, headers }); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index b40887fb4..2fbb72439 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -236,6 +236,19 @@ function getRefererLocation(request: Request, url: URL) { return new URL(import.meta.env.BASE_URL, url.origin).toString(); } +// The no-JS form path passes the submitted FormData as the last argument, and +// its entries are echoed back through the flash cookie. Any other no-JS POST +// (empty body, a non-form content type) leaves a value here that is not a +// FormData, so guard the entries() call instead of assuming it. +function buildFlashInput(parsed: any[]): unknown[] { + if (parsed.length === 0) { + return []; + } + const last = parsed[parsed.length - 1]; + const entries = typeof last?.entries === "function" ? [...last.entries()] : last; + return [...parsed.slice(0, -1), entries]; +} + async function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boolean) { const url = new URL(request.url); const isError = result instanceof Error; @@ -263,20 +276,23 @@ async function handleNoJS(result: any, request: Request, parsed: any[], thrown?: Location: getRefererLocation(request, url), }); if (result) { - headers.append( - "Set-Cookie", - `flash=${encodeURIComponent( - JSON.stringify({ - url: url.pathname + url.search, - result: isError ? result.message : result, - thrown: thrown, - error: isError, - input: parsed.length - ? [...parsed.slice(0, -1), [...parsed[parsed.length - 1].entries()]] - : [], - }), - )}; Secure; HttpOnly;`, - ); + try { + headers.append( + "Set-Cookie", + `flash=${encodeURIComponent( + JSON.stringify({ + url: url.pathname + url.search, + result: isError ? result.message : result, + thrown: thrown, + error: isError, + input: buildFlashInput(parsed), + }), + )}; Secure; HttpOnly;`, + ); + } catch { + // The flash cookie is best effort. A value that cannot be serialized must + // not take down the redirect, which is also this request's error handler. + } } return new Response(null, { status: statusCode,