diff --git a/.changeset/server-function-csrf.md b/.changeset/server-function-csrf.md new file mode 100644 index 000000000..f897d71f4 --- /dev/null +++ b/.changeset/server-function-csrf.md @@ -0,0 +1,7 @@ +--- +"@solidjs/start": patch +--- + +Reject cross-site server function requests to prevent CSRF. + +A `"use server"` function could be invoked by another site with the visitor's cookies, over a GET or a form POST, because the request was not checked. Requests to server functions are now allowed only from the same origin or same site. The check trusts the `Sec-Fetch-Site` header and falls back to comparing `Origin` against the request host, so a cross-site page can no longer trigger a server function. Same-origin calls, user-initiated navigations, and no-JS form submissions are unaffected. A separate origin that needs to call your backend should use an API route with explicit CORS. diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index 5ba67d3e2..ac5dedd43 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -336,3 +336,87 @@ describe("seroval stream response headers", () => { expect(h3Event.res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); }); }); + +describe("cross-site request rejection (CSRF)", () => { + const call = async (headers: Record, method = "POST") => { + const request = new Request("http://localhost/_server?id=fn", { method, headers }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + const fn = vi.fn(() => ({ ok: true })); + vi.mocked(getServerFunction).mockReturnValue(fn as never); + const { handleServerFunction } = await import("./handler.ts"); + const response = (await handleServerFunction(h3Event as never)) as Response; + return { response, fn }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects a cross-site request by Sec-Fetch-Site without running the function", async () => { + const { response, fn } = await call({ + "sec-fetch-site": "cross-site", + "x-server-instance": "server-fn:1", + }); + expect(response.status).toBe(403); + expect(fn).not.toHaveBeenCalled(); + }); + + it("rejects a cross-site GET navigation (Sec-Fetch-Site, no Origin)", async () => { + const { response, fn } = await call({ "sec-fetch-site": "cross-site" }, "GET"); + expect(response.status).toBe(403); + expect(fn).not.toHaveBeenCalled(); + }); + + it("rejects when Origin host differs and Sec-Fetch-Site is absent", async () => { + const { response, fn } = await call({ + origin: "https://evil.example", + "x-server-instance": "server-fn:1", + }); + expect(response.status).toBe(403); + expect(fn).not.toHaveBeenCalled(); + }); + + it("allows a same-origin request", async () => { + const { response, fn } = await call({ + "sec-fetch-site": "same-origin", + "x-server-instance": "server-fn:1", + }); + expect(response.status).not.toBe(403); + expect(fn).toHaveBeenCalled(); + }); + + it("allows a same-site request", async () => { + const { response, fn } = await call({ + "sec-fetch-site": "same-site", + "x-server-instance": "server-fn:1", + }); + expect(fn).toHaveBeenCalled(); + expect(response.status).not.toBe(403); + }); + + it("allows a user-initiated navigation (Sec-Fetch-Site: none)", async () => { + const { response, fn } = await call({ "sec-fetch-site": "none" }, "GET"); + expect(fn).toHaveBeenCalled(); + expect(response.status).not.toBe(403); + }); + + it("allows a matching Origin when Sec-Fetch-Site is absent", async () => { + const { response, fn } = await call({ + origin: "http://localhost", + "x-server-instance": "server-fn:1", + }); + expect(fn).toHaveBeenCalled(); + expect(response.status).not.toBe(403); + }); + + it("allows a request with neither header (non-browser client)", async () => { + const { fn } = await call({ "x-server-instance": "server-fn:1" }); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index bb866b606..b40887fb4 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -19,14 +19,53 @@ import { applyServerFunctionErrorHandler } from "./error-handler.ts"; import type { FetchEvent, PageEvent } from "../server/types.ts"; import { getExpectedRedirectStatus } from "../server/util.ts"; +/** + * Server functions are same-origin RPC. A cross-site page must not be able to + * invoke one with the visitor's cookies, so reject cross-site requests before + * the function runs. This is the token-less CSRF defense used by other + * frameworks: trust `Sec-Fetch-Site` when the browser sends it, and fall back + * to comparing `Origin` against the request host. + * + * `same-origin` and `same-site` are allowed, matching the reach of a + * `SameSite=Lax`/`Strict` cookie. `none` is a user-initiated navigation + * (typed URL, bookmark), not a request forged by another site. + */ +function isCrossSiteRequest(request: Request, url: URL): boolean { + const secFetchSite = request.headers.get("sec-fetch-site"); + if (secFetchSite) { + return secFetchSite === "cross-site"; + } + // Older browsers omit Sec-Fetch-Site. They still send Origin on the + // cross-site requests that matter (form and fetch POSTs), so compare it. + const origin = request.headers.get("origin"); + if (origin && origin !== "null") { + try { + return new URL(origin).host !== url.host; + } catch { + return true; + } + } + // No Origin either (a same-origin GET, or a non-browser client): nothing to + // reject on. + return false; +} + export async function handleServerFunction(h3Event: H3Event) { const event = getFetchEvent(h3Event); const request = event.request; + const url = new URL(request.url); + + if (isCrossSiteRequest(request, url)) { + return new Response( + import.meta.env.DEV ? "Cross-site server function requests are not allowed" : null, + { status: 403 }, + ); + } + const serverReference = request.headers.get("X-Server-Id"); const instance = request.headers.get("X-Server-Instance"); const singleFlight = request.headers.has("X-Single-Flight"); - const url = new URL(request.url); let functionId: string | undefined | null; if (serverReference) { // invariant(typeof serverReference === "string", "Invalid server function");