Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .changeset/nojs-server-function-crash.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/start/src/fns/handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>, method = "POST") => {
const request = new Request("http://localhost/_server?id=fn", { method, headers });
Expand Down
44 changes: 30 additions & 14 deletions packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading