From d4be37edccf7882571b6efd7d10667a7766812fa Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Thu, 20 Aug 2026 20:32:32 +0200 Subject: [PATCH] feat(web): protect server functions from cross-site requests --- .changeset/protect-server-functions-csrf.md | 5 ++ package.json | 2 +- .../solid-web/test/server/frame-hn.spec.tsx | 1 + .../test/server/response-invariants.spec.tsx | 1 + .../server/server-functions-csrf.spec.tsx | 51 +++++++++++++++++++ .../server-functions-extensions.spec.tsx | 15 ++++-- .../server-functions-single-flight.spec.tsx | 11 ++-- pnpm-lock.yaml | 10 ++-- 8 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 .changeset/protect-server-functions-csrf.md create mode 100644 packages/solid-web/test/server/server-functions-csrf.spec.tsx diff --git a/.changeset/protect-server-functions-csrf.md b/.changeset/protect-server-functions-csrf.md new file mode 100644 index 000000000..62b315fe7 --- /dev/null +++ b/.changeset/protect-server-functions-csrf.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Protect server-function requests from cross-site calls by default. diff --git a/package.json b/package.json index 43fc601af..04bc7c97a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/solid-web/test/server/frame-hn.spec.tsx b/packages/solid-web/test/server/frame-hn.spec.tsx index b7b15aefd..90699fa2e 100644 --- a/packages/solid-web/test/server/frame-hn.spec.tsx +++ b/packages/solid-web/test/server/frame-hn.spec.tsx @@ -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", diff --git a/packages/solid-web/test/server/response-invariants.spec.tsx b/packages/solid-web/test/server/response-invariants.spec.tsx index afe61be75..078b1b502 100644 --- a/packages/solid-web/test/server/response-invariants.spec.tsx +++ b/packages/solid-web/test/server/response-invariants.spec.tsx @@ -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" diff --git a/packages/solid-web/test/server/server-functions-csrf.spec.tsx b/packages/solid-web/test/server/server-functions-csrf.spec.tsx new file mode 100644 index 000000000..e2b70744a --- /dev/null +++ b/packages/solid-web/test/server/server-functions-csrf.spec.tsx @@ -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 = {}) { + 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); + }); +}); diff --git a/packages/solid-web/test/server/server-functions-extensions.spec.tsx b/packages/solid-web/test/server/server-functions-extensions.spec.tsx index 9fb4b1285..c5033ad63 100644 --- a/packages/solid-web/test/server/server-functions-extensions.spec.tsx +++ b/packages/solid-web/test/server/server-functions-extensions.spec.tsx @@ -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; }; @@ -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" } @@ -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"); diff --git a/packages/solid-web/test/server/server-functions-single-flight.spec.tsx b/packages/solid-web/test/server/server-functions-single-flight.spec.tsx index 0d75225b6..4c686b9c3 100644 --- a/packages/solid-web/test/server/server-functions-single-flight.spec.tsx +++ b/packages/solid-web/test/server/server-functions-single-flight.spec.tsx @@ -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" @@ -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[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; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87b5e1b6c..330dc05d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: 0.50.0-next.43 version: 0.50.0-next.43 '@dom-expressions/runtime': - specifier: 0.50.0-next.43 - version: 0.50.0-next.43(csstype@3.2.3)(seroval-plugins@1.5.5(seroval@1.5.5))(seroval@1.5.5) + specifier: 0.50.0-next.44 + version: 0.50.0-next.44(csstype@3.2.3)(seroval-plugins@1.5.5(seroval@1.5.5))(seroval@1.5.5) '@dom-expressions/tagged-jsx': specifier: 0.50.0-next.43 version: 0.50.0-next.43 @@ -1141,8 +1141,8 @@ packages: '@dom-expressions/hyperscript@0.50.0-next.43': resolution: {integrity: sha512-UtGx3s22K/kbTvPvWKdKk0RUXJuedcgqJ6kzUs81FZmL4Z8KL0mANq9W1Gvg36dN9eg+0txHhFcb8vIt8H4RZA==} - '@dom-expressions/runtime@0.50.0-next.43': - resolution: {integrity: sha512-x+TVqNuV0CD47R0KFLUqlLds5I6h4kL51t9YmVUV2RRSV3qO1DrClBcbVjpUTroBD8rz+0Xp+wG6NrAXQOjn1A==} + '@dom-expressions/runtime@0.50.0-next.44': + resolution: {integrity: sha512-Y33LPTEVQ7GRrcNDVo30mjwyaJ6C5XS5T+olc9VYkxr6EM0u5DwzZVDMggQe0FtGHcAS9/VjfAkHkuzzF9Pyiw==} hasBin: true peerDependencies: csstype: ^3.0 @@ -4680,7 +4680,7 @@ snapshots: '@dom-expressions/hyperscript@0.50.0-next.43': {} - '@dom-expressions/runtime@0.50.0-next.43(csstype@3.2.3)(seroval-plugins@1.5.5(seroval@1.5.5))(seroval@1.5.5)': + '@dom-expressions/runtime@0.50.0-next.44(csstype@3.2.3)(seroval-plugins@1.5.5(seroval@1.5.5))(seroval@1.5.5)': dependencies: babel-plugin-transform-rename-import: 2.3.0 csstype: 3.2.3