You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The single-flight request header is honoured on a GET. A GET()-declared read that answers with a cacheable Cache-Control therefore has two bodies at one url — the plain value, and a { value, data } envelope carrying whatever the flight hook produced for that request — and nothing names the variance.
scripted is the address, not the method (const scripted = address.data) — it is true for every call to <endpoint>/data/<id>, GET included. foldFlightData (server.ts:1172) then rewrites the body and sets the response header (server.ts:1191), while Cache-Control — the function's own, via respond() — is left exactly as the author wrote it. And a declared read skips withCSRFVary by design (server.ts:2219-2231, #3114), so no Vary is emitted at all:
GET | public, max-age=60, s-maxage=600 | Vary: (none) | PUBLIC MENU
GET + X-Single-Flight: true | public, max-age=60, s-maxage=600 | Vary: (none) | {"value":"PUBLIC MENU","data":{"true":{"/account":{"seenBy":"session=ATTACKER"}}}}
...and with no origin headers | public, max-age=60, s-maxage=600 | Vary: (none) | {"value":"PUBLIC MENU","data":{"true":{"/account":{"seenBy":"session=ATTACKER"}}}}
Same url, same cache key, two bodies, and the second one carries data the hook computed from the calling request — here from outcome.foldedHeaders, which is that caller's cookies (digestOutcome, server.ts:1237). This is #3094's class — a request header shaping a cacheable answer — reappearing on the new /data/ address through a different header.
Turning the origin gate back on does not name it either. With csrf: { protectDeclaredReads: true } the same call answers Vary: Sec-Fetch-Site, Origin, Referer — the two shapes still collide inside one bucket:
The header is the client's opt-in, and the shipped client already refuses to send it on a read — measured, not assumed:
client sent: GET /_server/data/menu | X-Single-Flight sent? false
That rule is written down in packages/web/server-functions/src/client.ts:469-478, with the reason attached:
// GET-encoded calls are reads (cacheable URLs) and stay plain — folding// per-request flight data into them would defeat caching.constflightSources=getFlightDataSourceIds();if(flightSources.length>0&&!options.read&&(!options.method||options.method.toUpperCase()!=="GET")){
So this is not self-inflicted by the bundled transport — it is an asymmetry: the client half of the rule exists, the server half does not. What makes it reachable anyway is that the server will fold for anyone who sends the header, and a declared read takes no origin proof (that is #3114's exemption, working as designed). No browser is needed:
...and with no origin headers | public, max-age=60, s-maxage=600 | Vary: (none) | {"value":"PUBLIC MENU",...}
One curl with X-Single-Flight: true against a cacheable declared read stores the envelope shape under the plain key for s-maxage=600. And the client unwraps any response carrying the header, whether or not that call asked for it (client.ts:678), so the stored slices are delivered into the next visitor's caches:
value returned to the caller : "PUBLIC MENU"
slices fed to THIS visitor : [{"/account":{"seenBy":"session=ATTACKER"}}]
Two directions, with different preconditions, and it is worth keeping them apart:
Unauthenticated edge poisoning — needs only (a) a GET() read whose response is publicly cacheable and (b) a flight hook configured. Anyone on the internet can warm the entry with a body of their shaping, and every subsequent reader's client feeds those slices to its flight consumers. No cookies, no CORS, no browser.
Cross-user data exposure — needs a cookie-bearing flighted GET to become the stored entry, i.e. first-party code doing the thing the bundled client declines to do (a custom transport, a config.fetch, an integration's own client, a hand-rolled fetch). Then the folding user's slices are what the cache serves to everyone else, as the repro shows.
What cannot reach it, : X-Single-Flight is not a CORS-safelisted request header, so a cross-origin fetch from a hostile page preflights first, and the preflight is refused with no CORS headers at all — measured: OPTIONS preflight -> 403 | Allow: null | ACAO: null. There is no drive-by browser attack here. The bare <endpoint>/<id> address is also unaffected — it ignores the header entirely (scripted is false), measured.
Steps to reproduce
# Run against a build of the `next` BRANCH — the /data/ address (#3094) and# the GET declaration are both newer than the published dist-tag.
git checkout next && pnpm install
pnpm --filter @solidjs/web build:js
node repro.mjs # from the repo root, so @solidjs/web resolves
repro.mjs:
import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();constsrv=awaitimport("@solidjs/web/server-functions/server");constcli=awaitimport("@solidjs/web/server-functions/client");const{ respond }=awaitimport("@solidjs/web");constURL_="http://app.example/_server/data/menu";constCACHEABLE="public, max-age=60, s-maxage=600";// a declared read whose answer is meant for a shared cachesrv.GET(srv.createServerReference(srv.registerServerReference("menu",async()=>respond("PUBLIC MENU",{headers: {"cache-control": CACHEABLE}}))));// a router-shaped single-flight hook: it re-runs reads under the CALLER's// folded cookies, so its slices are that caller's dataconstcollectFlightData=(event,outcome)=>({"/account": {seenBy: outcome.foldedHeaders.get("cookie")??"anonymous"}});constcall=headers=>srv.handleServerFunctionRequest(newRequest(URL_,{method: "GET", headers }),{ collectFlightData });constshow=async(label,headers)=>{constr=awaitcall(headers);console.log(label.padEnd(34),"|",r.headers.get("cache-control"),"| Vary:",r.headers.get("vary")??"(none)","|",awaitr.text());};console.log("--- one url, one cache key ---");awaitshow("GET",{});awaitshow("GET + X-Single-Flight: true",{[srv.SINGLE_FLIGHT_HEADER]: "true",cookie: "session=ATTACKER"});awaitshow("...and with no origin headers",{[srv.SINGLE_FLIGHT_HEADER]: "true",cookie: "session=ATTACKER","Sec-Fetch-Site": "cross-site"});console.log("\n--- what the SHIPPED client sends on a read ---");constseen=[],sent=[];cli.subscribeFlightData(data=>seen.push(data));conststored=awaitcall({[srv.SINGLE_FLIGHT_HEADER]: "true",cookie: "session=ATTACKER"});constbody=awaitstored.arrayBuffer(),headers=[...stored.headers];cli.configureServerFunctionsClient({fetch: async(url,init)=>(sent.push({url: String(url),method: init.method,headers: { ...init.headers}}),newResponse(body,{status: 200, headers }))});constvalue=awaitcli.GET(cli.createServerReference("menu"))();console.log("client sent:",sent[0].method,sent[0].url,"| X-Single-Flight sent?",srv.SINGLE_FLIGHT_HEADERinsent[0].headers);console.log("\n--- the visitor is served the stored entry ---");console.log("value returned to the caller :",JSON.stringify(value));console.log("slices fed to THIS visitor :",JSON.stringify(seen));
Output on next (d6a4a52f):
--- one url, one cache key ---
GET | public, max-age=60, s-maxage=600 | Vary: (none) | PUBLIC MENU
GET + X-Single-Flight: true | public, max-age=60, s-maxage=600 | Vary: (none) | {"value":"PUBLIC MENU","data":{"true":{"/account":{"seenBy":"session=ATTACKER"}}}}
...and with no origin headers | public, max-age=60, s-maxage=600 | Vary: (none) | {"value":"PUBLIC MENU","data":{"true":{"/account":{"seenBy":"session=ATTACKER"}}}}
--- what the SHIPPED client sends on a read ---
client sent: GET /_server/data/menu | X-Single-Flight sent? false
--- the visitor is served the stored entry ---
value returned to the caller : "PUBLIC MENU"
slices fed to THIS visitor : [{"/account":{"seenBy":"session=ATTACKER"}}]
Expected behavior
One url, one answer — the rule #3094 settled: "On the url, not a header, because shared caches key on the url and store one answer per key." A request header must not change the body of a response the function marked cacheable, or if it must, the response has to say so and must not be storable by a shared cache.
Fix
I tried one — it sits at the flight header is read on the address rather than the method — and a regression test that fails without it. Both are noise in an issue, so they are held for a PR rather than pasted here; say the word if you would rather see them inline.
Options
Never collect on a read — the diff above. Mirrors the client's own rule and its stated reason, keeps "one url, one answer" intact, costs nothing in-tree. The price is a capability removed rather than made safe: an integration that genuinely wanted collection on a read cannot have it, and it would find out by silently getting no data rather than by an error.
Make a folded response unstorable — when foldFlightData rewrote the body, force Cache-Control: private, no-store over whatever the function set. Honest about what the body now is, keeps the capability, and leaves the plain entry plain because the folded response is never stored. Costs: it silently overrides an author's explicit Cache-Control, which is its own surprise, and it does not stop a private cache from holding a per-request body under a key the app thinks is public.
Put the opt-in on the url, which is what A cacheable raw Response is shaped by a request header nothing keys on #3094 concluded for the response shape — a flight-collecting read would need its own address rather than a header. The most consistent with the rule already settled, and much the largest change.
Document it: a GET()-declared read must not carry a shared-cacheable Cache-Control in an app that registers flight hooks. Free, and it asks authors to hold an invariant the runtime is in a position to hold for them.
I would take (1), and (3) instead if collection on reads is a capability worth keeping. (2) is worth explicitly rejecting rather than leaving on the table.
A middle option, missing above: fold on a read only when the answer is not shared-cacheable — gate the fold on the response's own Cache-Control. That keeps the capability where it is safe and refuses it only where it poisons, which is the space between refusing the header on GET outright and naming it in Vary.
Describe the bug
The single-flight request header is honoured on a GET. A
GET()-declared read that answers with a cacheableCache-Controltherefore has two bodies at one url — the plain value, and a{ value, data }envelope carrying whatever the flight hook produced for that request — and nothing names the variance.scriptedis the address, not the method (const scripted = address.data) — it is true for every call to<endpoint>/data/<id>, GET included.foldFlightData(server.ts:1172) then rewrites the body and sets the response header (server.ts:1191), whileCache-Control— the function's own, viarespond()— is left exactly as the author wrote it. And a declared read skipswithCSRFVaryby design (server.ts:2219-2231, #3114), so noVaryis emitted at all:Same url, same cache key, two bodies, and the second one carries data the hook computed from the calling request — here from
outcome.foldedHeaders, which is that caller's cookies (digestOutcome, server.ts:1237). This is #3094's class — a request header shaping a cacheable answer — reappearing on the new/data/address through a different header.Turning the origin gate back on does not name it either. With
csrf: { protectDeclaredReads: true }the same call answersVary: Sec-Fetch-Site, Origin, Referer— the two shapes still collide inside one bucket:Why it matters in production
The header is the client's opt-in, and the shipped client already refuses to send it on a read — measured, not assumed:
That rule is written down in
packages/web/server-functions/src/client.ts:469-478, with the reason attached:So this is not self-inflicted by the bundled transport — it is an asymmetry: the client half of the rule exists, the server half does not. What makes it reachable anyway is that the server will fold for anyone who sends the header, and a declared read takes no origin proof (that is #3114's exemption, working as designed). No browser is needed:
One
curlwithX-Single-Flight: trueagainst a cacheable declared read stores the envelope shape under the plain key fors-maxage=600. And the client unwraps any response carrying the header, whether or not that call asked for it (client.ts:678), so the stored slices are delivered into the next visitor's caches:Two directions, with different preconditions, and it is worth keeping them apart:
GET()read whose response is publicly cacheable and (b) a flight hook configured. Anyone on the internet can warm the entry with a body of their shaping, and every subsequent reader's client feeds those slices to its flight consumers. No cookies, no CORS, no browser.config.fetch, an integration's own client, a hand-rolled fetch). Then the folding user's slices are what the cache serves to everyone else, as the repro shows.What cannot reach it, :
X-Single-Flightis not a CORS-safelisted request header, so a cross-originfetchfrom a hostile page preflights first, and the preflight is refused with no CORS headers at all — measured:OPTIONS preflight -> 403 | Allow: null | ACAO: null. There is no drive-by browser attack here. The bare<endpoint>/<id>address is also unaffected — it ignores the header entirely (scriptedis false), measured.Steps to reproduce
repro.mjs:Output on
next(d6a4a52f):Expected behavior
One url, one answer — the rule #3094 settled: "On the url, not a header, because shared caches key on the url and store one answer per key." A request header must not change the body of a response the function marked cacheable, or if it must, the response has to say so and must not be storable by a shared cache.
Fix
I tried one — it sits at the flight header is read on the address rather than the method — and a regression test that fails without it. Both are noise in an issue, so they are held for a PR rather than pasted here; say the word if you would rather see them inline.
Options
headers.append("Vary", SINGLE_FLIGHT_HEADER)insidefoldFlightData. This is the RFC-correct answer and the one that looks right, and I think it is the wrong one here: it re-introduces exactly the fragmentationGET()skips the CSRFVaryto avoid (Server function responses ship no Cache-Control, and a CSRF Vary that defeats GET caching #3071, A GET-declared read is invocable cross-site, and nothing enforces that it is a read #3114), and on a CDN configured to ignoreVaryit changes nothing — which is precisely the deployment where one caller's slices get served to another.foldFlightDatarewrote the body, forceCache-Control: private, no-storeover whatever the function set. Honest about what the body now is, keeps the capability, and leaves the plain entry plain because the folded response is never stored. Costs: it silently overrides an author's explicitCache-Control, which is its own surprise, and it does not stop a private cache from holding a per-request body under a key the app thinks is public.GET()-declared read must not carry a shared-cacheableCache-Controlin an app that registers flight hooks. Free, and it asks authors to hold an invariant the runtime is in a position to hold for them.I would take (1), and (3) instead if collection on reads is a capability worth keeping. (2) is worth explicitly rejecting rather than leaving on the table.
A middle option, missing above: fold on a read only when the answer is not shared-cacheable — gate the fold on the response's own
Cache-Control. That keeps the capability where it is safe and refuses it only where it poisons, which is the space between refusing the header on GET outright and naming it inVary.Related
Varyhere at all: declared reads skip the origin gate, deliberately, to keep their cache entries shareable. That exemption is not at fault, but it is what removes the header that would otherwise have fragmented these two shapes apart by accident.Vary-versus-cache tradeoff was settled, and the reason option (2) is not the free win it looks like.Environment
@solidjs/webnext(d6a4a52f)