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/server-function-csrf.md
Original file line number Diff line number Diff line change
@@ -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.
84 changes: 84 additions & 0 deletions packages/start/src/fns/handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>, 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();
});
});
41 changes: 40 additions & 1 deletion packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading