Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/protect-server-functions-csrf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Protect server-function requests from cross-site calls by default.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@changesets/cli": "^2.25.2",
"@dom-expressions/babel-plugin-jsx": "0.50.0-next.43",
"@dom-expressions/hyperscript": "0.50.0-next.43",
"@dom-expressions/runtime": "0.50.0-next.43",
"@dom-expressions/runtime": "0.50.0-next.44",
"@dom-expressions/tagged-jsx": "0.50.0-next.43",
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-commonjs": "^24.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/solid-web/test/server/frame-hn.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function fetchStory(storyId: string) {
new Request("http://localhost/_server?id=getStory", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
[FUNCTION_HEADER]: "getStory",
[INSTANCE_HEADER]: "1",
"Content-Type": "text/plain",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe("error sanitization passes thrown Responses through (production bundle)
new Request(`http://localhost/_server?id=${encodeURIComponent(id)}`, {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
"content-type": "application/json",
"X-Server-Function-Format": "1"
Expand Down
51 changes: 51 additions & 0 deletions packages/solid-web/test/server/server-functions-csrf.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import {
handleServerFunctionRequest,
registerServerFunction
} from "@solidjs/web/server-functions/server";
import type { ServerFunctionCSRFOptions } from "@solidjs/web/server-functions/server";

const provideEvent = (_event: unknown, run: () => unknown) => run();

function request(id: string, headers: Record<string, string> = {}) {
return new Request("https://app.example/_server", {
method: "POST",
headers: {
...headers,
"X-Server-Function-Id": id,
"X-Server-Function-Instance": "server-function:test"
}
});
}

describe("server-function CSRF bridge", () => {
it("rejects cross-site calls before dispatch", async () => {
const fn = vi.fn(async () => "ok");
registerServerFunction("csrf-bridge-cross-site", fn);

const rejected = await handleServerFunctionRequest(
request("csrf-bridge-cross-site", { "Sec-Fetch-Site": "cross-site" }),
{ provideEvent }
);
expect(rejected.status).toBe(403);
expect(fn).not.toHaveBeenCalled();

const accepted = await handleServerFunctionRequest(
request("csrf-bridge-cross-site", { "Sec-Fetch-Site": "same-origin" }),
{ provideEvent }
);
expect(accepted.status).toBe(200);
expect(fn).toHaveBeenCalledOnce();
});

it("exposes trusted-origin configuration", async () => {
registerServerFunction("csrf-bridge-origin", async () => "ok");
const csrf: ServerFunctionCSRFOptions = { origin: "https://trusted.example" };

const response = await handleServerFunctionRequest(
request("csrf-bridge-origin", { Origin: "https://trusted.example" }),
{ csrf, provideEvent }
);
expect(response.status).toBe(200);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ afterAll(() => {
// handler — a full round trip through both published bundles.
function connectTransport() {
const original = globalThis.fetch;
globalThis.fetch = ((url: string, init?: RequestInit) =>
handleServerFunctionRequest(
new Request(new URL(url, "http://localhost"), init)
)) as typeof fetch;
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const request = new Request(new URL(url, "http://localhost"), init);
request.headers.set("Sec-Fetch-Site", "same-origin");
return handleServerFunctionRequest(request);
}) as typeof fetch;
return () => {
globalThis.fetch = original;
};
Expand All @@ -78,6 +79,7 @@ describe("server-function extension surface (built bundles)", () => {
new Request("http://localhost/_server", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Id": "ext-get-0",
"X-Server-Function-Instance": "server-function:test"
}
Expand All @@ -88,7 +90,10 @@ describe("server-function extension surface (built bundles)", () => {
// and GET without a declaration answers 405 too
registerServerFunction("ext-post-0", async () => "x");
const undeclared = await handleServerFunctionRequest(
new Request("http://localhost/_server?id=ext-post-0", { method: "GET" })
new Request("http://localhost/_server?id=ext-post-0", {
method: "GET",
headers: { "Sec-Fetch-Site": "same-origin" }
})
);
expect(undeclared.status).toBe(405);
expect(undeclared.headers.get("Allow")).toBe("POST");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function flightRequest(id: string) {
return new Request("http://localhost/_server", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Id": id,
"X-Server-Function-Instance": "server-function:test",
[SINGLE_FLIGHT_HEADER]: "true"
Expand Down Expand Up @@ -106,11 +107,11 @@ describe("single-flight client bridge (built client bundle)", () => {
// handler — a full round trip through both published bundles.
function connectTransport(options?: Parameters<typeof handleServerFunctionRequest>[1]) {
const original = globalThis.fetch;
globalThis.fetch = ((url: string, init?: RequestInit) =>
handleServerFunctionRequest(
new Request(new URL(url, "http://localhost"), init),
options
)) as typeof fetch;
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const request = new Request(new URL(url, "http://localhost"), init);
request.headers.set("Sec-Fetch-Site", "same-origin");
return handleServerFunctionRequest(request, options);
}) as typeof fetch;
return () => {
globalThis.fetch = original;
};
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading