From 91af70a754bb9ef39c42986d052dbc9682b09fbb Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 16 Sep 2026 17:36:17 +0800 Subject: [PATCH] fix(fns): guard the no-JS flash cookie against non-form arguments An unauthenticated no-JS POST with an empty or non-form body left `undefined` (or another non-FormData value) as the last parsed argument. Building the flash cookie called `.entries()` on it and threw. handleNoJS is also the catch-path error handler, so it rethrew on the same input and the request died with a 500. - Only spread `.entries()` when the last argument actually is a FormData; otherwise pass the value through. - Wrap the flash cookie construction so an unserializable value degrades to a plain redirect instead of throwing out of the error handler. - Add regression tests for a non-form body, an empty body, and the form case. Co-Authored-By: Claude Opus 4.8 --- .changeset/nojs-server-function-crash.md | 7 +++ packages/start/src/fns/handler.spec.ts | 59 ++++++++++++++++++++++++ packages/start/src/fns/handler.ts | 44 ++++++++++++------ 3 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 .changeset/nojs-server-function-crash.md 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 5ba67d3e2..69c077c08 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -336,3 +336,62 @@ describe("seroval stream response headers", () => { expect(h3Event.res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); }); }); + +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"); + }); +}); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index bb866b606..e4f568da5 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -197,6 +197,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; @@ -224,20 +237,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,