Repository navigation
Server function responses ship no Cache-Control, and a CSRF Vary that defeats GET caching #3071
Description
Describe the bug
Two defaults pull server function responses in the wrong direction: responses that must not be
cached carry nothing that says so, and responses that exist to be cached carry a Vary that
prevents it.
1. No Cache-Control on any response. encodeResult
(packages/web/server-functions/src/server.ts:1290-1331 on next) sets Content-Type and the
format header; the only no-store in the file is on the 403 path (server.ts:1475). The docs
present caching as opt-in via respond(data, { headers: { "cache-control": … } }), but on the
wire nothing marks the default as uncacheable, and CDN zones with override-TTL or "cache
everything" rules (Bunny, Cloudflare Cache Rules) will store per-user responses.
2. Every response carries Vary: Sec-Fetch-Site, Origin, Referer from the same-origin gate
(server.ts:1444-1469, applied at 1864) — including successful GET responses from
GET(fn)-declared functions. Those three headers are exactly the ones that differ between a
navigation and a fetch, and Referer is per-page, so a shared cache fragments per referring
page. CDNs that honor Vary only for Accept-Encoding (Cloudflare) ignore it and serve one
entry across every context instead, which is the worse failure.
Steps to reproduce
mkdir sf-repro && cd sf-repro && npm init -y
npm i @solidjs/web@2.0.0-rc.3 # pulls solid-js@2.0.0-rc.3
node --conditions=development repro.mjs # the dev condition only makes the error text readablerepro.mjs:
import { GET, configureServerFunctionsServer, createServerReference, handleServerFunctionRequest, registerServerReference } from "@solidjs/web/server-functions";
import { provideRequestEvent } from "@solidjs/web/storage";
configureServerFunctionsServer({ provideEvent: (event, run) => provideRequestEvent(event, run) });
GET(createServerReference(registerServerReference("pub-0", async () => ({ who: "public menu" }))));
const res = await handleServerFunctionRequest(
new Request("http://app.test/_server?id=pub-0", { headers: { "Sec-Fetch-Site": "same-origin" } })
);
console.log(res.status, JSON.stringify(Object.fromEntries(res.headers)));Output on @solidjs/web@2.0.0-rc.3:
200 {"content-type":"application/json","vary":"Sec-Fetch-Site, Origin, Referer","x-server-function-format":"8"}
Expected behavior
- Emit
Cache-Control: no-storeunless the function set its own, so caching is opt-in on the
wire the way the documentation describes it in prose. - Don't attach the CSRF
Varyto responses fromGET-declared functions (or make the gate
opt-out per function). A cross-site GET cannot read the response body — that is what CORS
enforces — andSameSitegoverns the ambient credentials, so for a declared read the gate
buys little and costs the caching the helper exists for.
Environment
@solidjs/web |
2.0.0-rc.3 (published) |
solid-js |
2.0.0-rc.3 |
| Node | v24.19.0 |
| OS | macOS (darwin 25.6.0) |
| Code references | next @ f5939ef |
Activity
frenzzy commentedon Aug 27, 2026
Design context: this came out of a broader look at how server function calls are addressed — #3072. This issue is independent of that discussion and can be fixed on its own.
fix(web): method allowlist, HEAD support, and cache hygiene for serve…
ryansolid commentedon Aug 27, 2026
Fixed in
258c76adonnext, both halves as you proposed:Cache-Control: no-storeunless the function set its own policy (viarespond()headers or a returnedResponse) — caching is opt-in on the wire, matching the prose. Error paths, 404s, and 405s included.GET-declared functions skip the CSRF origin gate entirely, so theVary: Sec-Fetch-Site, Origin, Refereris gone from exactly the responses the helper exists to make cacheable. We went with skipping the gate rather than just dropping theVary: if those headers could still 403 the request, theVarywould have to stay, so the only coherent way to remove it is to not let them affect declared reads at all. Cross-site reading is what same-origin policy already blocks, and declared GETs are safe by contract. POST dispatch stays gated as before.Will be in the next RC. Thanks for the pair of reports — the wire behavior actually matches the docs now.
fix(web): let the csrf.origin allowlist admit cross-origin callers, w…