Skip to content

Commit 258c76a

Browse files
fix(web): method allowlist, HEAD support, and cache hygiene for server function requests (#3069, #3071)
HEAD (and PUT/DELETE/PATCH) requests bypassed the GET method gate and executed any registered server function. The gate is now an allowlist: POST always dispatches, GET/HEAD dispatch only to GET-declared functions (HEAD strips the body per spec), everything else answers 405. Responses default to Cache-Control: no-store unless the function set its own policy, and declared reads skip the CSRF origin gate so their responses drop the Vary: Sec-Fetch-Site, Origin, Referer that defeated shared-cache storage of GET responses. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 30a633e commit 258c76a

3 files changed

Lines changed: 293 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Harden the server function handler's HTTP layer. The method gate is now an allowlist: POST always dispatches, GET and HEAD dispatch only to `GET`-declared functions, and every other verb answers 405 — previously a HEAD (or PUT/DELETE/PATCH) request bypassed the GET gate entirely and executed any registered function with attacker-chosen query arguments (#3069). HEAD runs the function like GET and strips the body per spec. Responses now default to `Cache-Control: no-store` unless the function set its own cache policy, and GET/HEAD requests to `GET`-declared functions skip the CSRF origin gate so their responses no longer carry the `Vary: Sec-Fetch-Site, Origin, Referer` that fragmented shared-cache entries — declared reads are protected by same-origin policy, and caching becomes opt-in on the wire instead of just in prose (#3071).

packages/web/server-functions/src/server.ts

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,17 +1491,26 @@ function forbiddenResponse() {
14911491
);
14921492
} /**
14931493
* Web-standard HTTP handler for server function calls: resolves the
1494-
* function id from the request, gates GET dispatch on the declaration (405
1495-
* for a GET request to a function that never declared `GET`; POST is always
1496-
* accepted), decodes arguments, runs the function under a request-event scope,
1497-
* and encodes the result (forwarding redirect/revalidation metadata
1498-
* through headers). Mount it on the endpoint the client transport targets
1499-
* (default `/_server`); platform adapters (h3, express, ...) convert their
1500-
* request shape to a web `Request` around it.
1494+
* function id from the request, enforces the method allowlist (POST always
1495+
* dispatches; GET and HEAD dispatch only to functions that declared `GET`,
1496+
* with HEAD returning the equivalent GET's status and headers minus the
1497+
* body; every other method answers 405), decodes arguments, runs the
1498+
* function under a request-event scope, and encodes the result (forwarding
1499+
* redirect/revalidation metadata through headers). Mount it on the endpoint
1500+
* the client transport targets (default `/_server`); platform adapters (h3,
1501+
* express, ...) convert their request shape to a web `Request` around it.
15011502
*
15021503
* Requests are same-origin by default. The handler accepts browser requests
15031504
* proven by `Sec-Fetch-Site`, `Origin`, or `Referer`, and rejects requests
1504-
* without usable metadata unless explicitly configured otherwise.
1505+
* without usable metadata unless explicitly configured otherwise. GET/HEAD
1506+
* requests to `GET`-declared functions skip this gate: they are reads by
1507+
* contract, cross-site response READING is already blocked by same-origin
1508+
* policy, and skipping it keeps the `Vary: Sec-Fetch-Site, Origin, Referer`
1509+
* it would impose off the responses shared caches are meant to store.
1510+
*
1511+
* Every response leaves with `Cache-Control: no-store` unless the function
1512+
* set its own cache policy (via `respond()` headers or a returned
1513+
* `Response`) — caching is opt-in on the wire, not just in prose.
15051514
*
15061515
* When the event carries a `response` head stub (`event.response`, see the
15071516
* server entry's `ResponseStub`), the handler folds it onto every outgoing
@@ -1608,17 +1617,28 @@ export function handleServerFunctionRequest(
16081617
export async function handleServerFunctionRequest(request, options = {}) {
16091618
const codec = options.codec !== undefined ? options.codec : getServerFunctionsCodec();
16101619
const url = new URL(request.url);
1620+
const method = request.method;
1621+
const functionId = resolveFunctionId(request, url);
1622+
// GET-declared functions are reads by contract, and the read methods are
1623+
// exactly where the CSRF gate costs more than it buys: same-origin policy
1624+
// already prevents a cross-site caller from READING the response, while
1625+
// the gate's `Vary: Sec-Fetch-Site, Origin, Referer` fragments (or, on
1626+
// CDNs that ignore Vary, poisons) the shared-cache entries the GET helper
1627+
// exists to enable (#3071). State-changing dispatch (POST) stays gated.
1628+
const declaredRead =
1629+
(method === "GET" || method === "HEAD") &&
1630+
functionId !== null &&
1631+
METHODS.get(functionId) === "GET";
16111632
const csrf = options.csrf !== undefined ? options.csrf : config.csrf;
1612-
const protectsRequest = csrf !== false;
1633+
const protectsRequest = csrf !== false && !declaredRead;
16131634
if (protectsRequest && !(await allowsServerFunctionRequest(request, csrf === true ? {} : csrf))) {
1614-
return forbiddenResponse();
1635+
return finalizeTransportResponse(forbiddenResponse(), method);
16151636
}
16161637
const instance = request.headers.get(INSTANCE_HEADER);
1617-
const functionId = resolveFunctionId(request, url);
16181638

16191639
if (!functionId) {
16201640
const response = new Response(DEV ? "Server function not found" : null, { status: 404 });
1621-
return protectsRequest ? withCSRFVary(response) : response;
1641+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
16221642
}
16231643

16241644
let serverFunction;
@@ -1628,24 +1648,27 @@ export async function handleServerFunctionRequest(request, options = {}) {
16281648
const response = new Response(DEV ? `Unknown server function: ${functionId}` : null, {
16291649
status: 404
16301650
});
1631-
return protectsRequest ? withCSRFVary(response) : response;
1651+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
16321652
}
16331653

1634-
// method enforcement: GET requests only dispatch to functions that
1635-
// declared GET (the server half of `GET` records them) — no crafted GET
1636-
// URLs against functions that never opted in. Declaring GET grants GET
1637-
// without revoking POST: the same function stays callable over the
1638-
// default transport (e.g. a query()-wrapped function also called
1639-
// directly).
1640-
if (request.method === "GET" && METHODS.get(functionId) !== "GET") {
1654+
// Method allowlist: POST always dispatches (the default transport);
1655+
// GET and HEAD dispatch only to functions that declared GET (the server
1656+
// half of `GET` records them) — no crafted read URLs against functions
1657+
// that never opted in, and no side door through OTHER verbs either
1658+
// (before #3069 a HEAD — sent freely by link checkers, uptime probes and
1659+
// prefetchers — bypassed the gate entirely and executed any registered
1660+
// function). Declaring GET grants the read methods without revoking POST:
1661+
// the same function stays callable over the default transport (e.g. a
1662+
// query()-wrapped function also called directly).
1663+
if (method !== "POST" && !declaredRead) {
16411664
const response = new Response(
16421665
DEV ? `Method not allowed for server function: ${functionId}` : null,
16431666
{
16441667
status: 405,
1645-
headers: { Allow: "POST" }
1668+
headers: { Allow: METHODS.get(functionId) === "GET" ? "POST, GET, HEAD" : "POST" }
16461669
}
16471670
);
1648-
return protectsRequest ? withCSRFVary(response) : response;
1671+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
16491672
}
16501673

16511674
const event = options.createEvent ? options.createEvent(request) : { request, locals: {} };
@@ -1875,5 +1898,47 @@ export async function handleServerFunctionRequest(request, options = {}) {
18751898
}
18761899
};
18771900
const response = commitEventResponse(await dispatch(), event);
1878-
return protectsRequest ? withCSRFVary(response) : response;
1901+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
1902+
}
1903+
1904+
/**
1905+
* Last-mile transport hygiene applied to every response leaving the handler.
1906+
*
1907+
* - `Cache-Control: no-store` unless the function set its own (via
1908+
* `respond()` headers or a returned Response): caching is opt-in ON THE
1909+
* WIRE the way the docs describe it in prose. Without the default, CDN
1910+
* zones with override-TTL or "cache everything" rules store per-user RPC
1911+
* responses (#3071).
1912+
* - HEAD responses drop their body, as HTTP requires — the function still
1913+
* ran (HEAD is gated identically to GET), so status and headers are those
1914+
* of the equivalent GET (#3069).
1915+
*/
1916+
function finalizeTransportResponse(response, method) {
1917+
const stripBody = method === "HEAD" && response.body !== null;
1918+
if (stripBody || !response.headers.has("Cache-Control")) {
1919+
try {
1920+
if (!response.headers.has("Cache-Control")) {
1921+
response.headers.set("Cache-Control", "no-store");
1922+
}
1923+
if (!stripBody) return response;
1924+
// discard, don't leak: the encoded body may be a live codec stream
1925+
response.body.cancel().catch(() => {});
1926+
return new Response(null, {
1927+
status: response.status,
1928+
statusText: response.statusText,
1929+
headers: response.headers
1930+
});
1931+
} catch {
1932+
// immutable headers (e.g. a raw fetch() Response passed through)
1933+
const headers = new Headers(response.headers);
1934+
if (!headers.has("Cache-Control")) headers.set("Cache-Control", "no-store");
1935+
if (stripBody) response.body.cancel().catch(() => {});
1936+
return new Response(stripBody ? null : response.body, {
1937+
status: response.status,
1938+
statusText: response.statusText,
1939+
headers
1940+
});
1941+
}
1942+
}
1943+
return response;
18791944
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* HTTP-layer hygiene of `handleServerFunctionRequest` (#3069, #3071):
3+
*
4+
* - The method allowlist: POST always dispatches; GET and HEAD dispatch only
5+
* to `GET`-declared functions (HEAD used to bypass the gate entirely and
6+
* execute any registered function); every other verb answers 405.
7+
* - HEAD responses carry the equivalent GET's status and headers, minus the
8+
* body.
9+
* - GET/HEAD requests to declared functions skip the CSRF gate, so their
10+
* responses carry no `Vary: Sec-Fetch-Site, Origin, Referer` and shared
11+
* caches can store them. POST dispatch stays gated.
12+
* - Every response defaults to `Cache-Control: no-store` unless the function
13+
* set its own cache policy — caching is opt-in on the wire.
14+
*
15+
* Runs against the built bundles like the other server-function specs.
16+
*/
17+
import { describe, expect, it, vi } from "vitest";
18+
import {
19+
GET as serverGET,
20+
createServerReference,
21+
handleServerFunctionRequest,
22+
registerServerFunction,
23+
registerServerReference
24+
} from "@solidjs/web/server-functions/server";
25+
26+
const provideEvent = <T,>(_event: unknown, run: () => T): T => run();
27+
28+
function readRequest(id: string, method: string, headers: Record<string, string> = {}) {
29+
return new Request(`https://app.example/_server?id=${id}`, { method, headers });
30+
}
31+
32+
function postRequest(id: string, headers: Record<string, string> = {}) {
33+
return new Request("https://app.example/_server", {
34+
method: "POST",
35+
headers: {
36+
"Sec-Fetch-Site": "same-origin",
37+
...headers,
38+
"X-Server-Function-Id": id,
39+
"X-Server-Function-Instance": "server-function:test"
40+
}
41+
});
42+
}
43+
44+
function declareGET(id: string, fn: (...args: any[]) => any) {
45+
serverGET(createServerReference(registerServerReference(id, fn)));
46+
}
47+
48+
describe("server-function method allowlist (#3069)", () => {
49+
it("gates HEAD exactly like GET: 405 for undeclared functions, nothing executes", async () => {
50+
const fn = vi.fn(async () => "side effect happened");
51+
registerServerFunction("hygiene-post-only", fn);
52+
53+
for (const method of ["HEAD", "GET"]) {
54+
const response = await handleServerFunctionRequest(
55+
readRequest("hygiene-post-only", method, { "Sec-Fetch-Site": "same-origin" }),
56+
{ provideEvent }
57+
);
58+
expect(response.status).toBe(405);
59+
expect(response.headers.get("Allow")).toBe("POST");
60+
}
61+
expect(fn).not.toHaveBeenCalled();
62+
});
63+
64+
it("rejects other verbs too — the gate is an allowlist, not a GET special case", async () => {
65+
const fn = vi.fn(async () => "ok");
66+
registerServerFunction("hygiene-verbs", fn);
67+
68+
for (const method of ["PUT", "DELETE", "PATCH"]) {
69+
const response = await handleServerFunctionRequest(
70+
new Request("https://app.example/_server?id=hygiene-verbs", {
71+
method,
72+
headers: { "Sec-Fetch-Site": "same-origin" }
73+
}),
74+
{ provideEvent }
75+
);
76+
expect(response.status).toBe(405);
77+
}
78+
expect(fn).not.toHaveBeenCalled();
79+
80+
// and on a GET-declared function the Allow header advertises the reads
81+
declareGET("hygiene-declared-verbs", async () => "ok");
82+
const response = await handleServerFunctionRequest(
83+
new Request("https://app.example/_server?id=hygiene-declared-verbs", {
84+
method: "PUT",
85+
headers: { "Sec-Fetch-Site": "same-origin" }
86+
}),
87+
{ provideEvent }
88+
);
89+
expect(response.status).toBe(405);
90+
expect(response.headers.get("Allow")).toBe("POST, GET, HEAD");
91+
});
92+
93+
it("HEAD on a declared function runs it and returns the GET response minus the body", async () => {
94+
let calls = 0;
95+
declareGET("hygiene-head-ok", async (n: number = 1) => {
96+
calls++;
97+
return { doubled: n * 2 };
98+
});
99+
100+
const get = await handleServerFunctionRequest(readRequest("hygiene-head-ok", "GET"), {
101+
provideEvent
102+
});
103+
expect(get.status).toBe(200);
104+
expect(await get.json()).toEqual({ doubled: 2 });
105+
106+
const head = await handleServerFunctionRequest(readRequest("hygiene-head-ok", "HEAD"), {
107+
provideEvent
108+
});
109+
expect(head.status).toBe(200);
110+
expect(head.body).toBeNull();
111+
expect(head.headers.get("Content-Type")).toBe(get.headers.get("Content-Type"));
112+
expect(calls).toBe(2);
113+
});
114+
});
115+
116+
describe("server-function cache hygiene (#3071)", () => {
117+
it("declared reads skip the CSRF gate: no origin proof required, no Vary emitted", async () => {
118+
declareGET("hygiene-read-ungated", async () => ({ who: "public menu" }));
119+
120+
// no Sec-Fetch-Site / Origin / Referer at all — a CDN edge or curl
121+
const bare = await handleServerFunctionRequest(readRequest("hygiene-read-ungated", "GET"), {
122+
provideEvent
123+
});
124+
expect(bare.status).toBe(200);
125+
expect(bare.headers.get("Vary")).toBeNull();
126+
127+
// even explicitly cross-site: SOP already blocks cross-origin reads
128+
const crossSite = await handleServerFunctionRequest(
129+
readRequest("hygiene-read-ungated", "GET", { "Sec-Fetch-Site": "cross-site" }),
130+
{ provideEvent }
131+
);
132+
expect(crossSite.status).toBe(200);
133+
});
134+
135+
it("POST dispatch stays gated and keeps its Vary", async () => {
136+
const fn = vi.fn(async () => "ok");
137+
registerServerFunction("hygiene-post-gated", fn);
138+
139+
const rejected = await handleServerFunctionRequest(
140+
postRequest("hygiene-post-gated", { "Sec-Fetch-Site": "cross-site" }),
141+
{ provideEvent }
142+
);
143+
expect(rejected.status).toBe(403);
144+
expect(fn).not.toHaveBeenCalled();
145+
146+
const accepted = await handleServerFunctionRequest(postRequest("hygiene-post-gated"), {
147+
provideEvent
148+
});
149+
expect(accepted.status).toBe(200);
150+
expect(accepted.headers.get("Vary")).toBe("Sec-Fetch-Site, Origin, Referer");
151+
});
152+
153+
it("defaults every response to Cache-Control: no-store", async () => {
154+
registerServerFunction("hygiene-no-store", async () => ({ private: true }));
155+
declareGET("hygiene-no-store-get", async () => ({ public: true }));
156+
157+
const post = await handleServerFunctionRequest(postRequest("hygiene-no-store"), {
158+
provideEvent
159+
});
160+
expect(post.status).toBe(200);
161+
expect(post.headers.get("Cache-Control")).toBe("no-store");
162+
163+
// opt-in stays opt-in even for declared reads
164+
const get = await handleServerFunctionRequest(readRequest("hygiene-no-store-get", "GET"), {
165+
provideEvent
166+
});
167+
expect(get.headers.get("Cache-Control")).toBe("no-store");
168+
169+
const missing = await handleServerFunctionRequest(
170+
readRequest("hygiene-nonexistent", "GET", { "Sec-Fetch-Site": "same-origin" }),
171+
{ provideEvent }
172+
);
173+
expect(missing.status).toBe(404);
174+
expect(missing.headers.get("Cache-Control")).toBe("no-store");
175+
176+
const undeclared = await handleServerFunctionRequest(
177+
readRequest("hygiene-no-store", "GET", { "Sec-Fetch-Site": "same-origin" }),
178+
{ provideEvent }
179+
);
180+
expect(undeclared.status).toBe(405);
181+
expect(undeclared.headers.get("Cache-Control")).toBe("no-store");
182+
});
183+
184+
it("preserves a cache policy the function set itself", async () => {
185+
declareGET(
186+
"hygiene-opt-in",
187+
async () => new Response(null, { headers: { "Cache-Control": "public, max-age=60" } })
188+
);
189+
190+
const response = await handleServerFunctionRequest(
191+
new Request("https://app.example/_server?id=hygiene-opt-in", {
192+
method: "GET",
193+
headers: { "X-Server-Function-Instance": "server-function:test" }
194+
}),
195+
{ provideEvent }
196+
);
197+
expect(response.status).toBe(200);
198+
expect(response.headers.get("Cache-Control")).toBe("public, max-age=60");
199+
});
200+
});

0 commit comments

Comments
 (0)