Skip to content

The X-Single-Flight request header reshapes a cacheable GET body, and nothing names the variance #3128

Description

@frenzzy

Describe the bug

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.

// packages/web/server-functions/src/server.ts:2361
const flightHeader = scripted ? request.headers.get(SINGLE_FLIGHT_HEADER) : null;

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:

protectDeclaredReads:true | Vary: Sec-Fetch-Site, Origin, Referer | CC: public, s-maxage=600 | {"value":"PUBLIC","data":{"true":{"/a":"per-request"}}}

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:

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.
const flightSources = 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")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
const cli = await import("@solidjs/web/server-functions/client");
const { respond } = await import("@solidjs/web");

const URL_ = "http://app.example/_server/data/menu";
const CACHEABLE = "public, max-age=60, s-maxage=600";

// a declared read whose answer is meant for a shared cache
srv.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 data
const collectFlightData = (event, outcome) =>
  ({ "/account": { seenBy: outcome.foldedHeaders.get("cookie") ?? "anonymous" } });

const call = headers =>
  srv.handleServerFunctionRequest(new Request(URL_, { method: "GET", headers }), { collectFlightData });

const show = async (label, headers) => {
  const r = await call(headers);
  console.log(label.padEnd(34), "|", r.headers.get("cache-control"),
    "| Vary:", r.headers.get("vary") ?? "(none)", "|", await r.text());
};

console.log("--- one url, one cache key ---");
await show("GET", {});
await show("GET + X-Single-Flight: true", { [srv.SINGLE_FLIGHT_HEADER]: "true", cookie: "session=ATTACKER" });
await show("...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 ---");
const seen = [], sent = [];
cli.subscribeFlightData(data => seen.push(data));
const stored = await call({ [srv.SINGLE_FLIGHT_HEADER]: "true", cookie: "session=ATTACKER" });
const body = await stored.arrayBuffer(), headers = [...stored.headers];
cli.configureServerFunctionsClient({
  fetch: async (url, init) => (sent.push({ url: String(url), method: init.method, headers: { ...init.headers } }),
    new Response(body, { status: 200, headers }))
});
const value = await cli.GET(cli.createServerReference("menu"))();
console.log("client sent:", sent[0].method, sent[0].url,
  "| X-Single-Flight sent?", srv.SINGLE_FLIGHT_HEADER in sent[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

  1. 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.
  2. Name the varianceheaders.append("Vary", SINGLE_FLIGHT_HEADER) inside foldFlightData. 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 fragmentation GET() skips the CSRF Vary to 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 ignore Vary it changes nothing — which is precisely the deployment where one caller's slices get served to another.
  3. 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.
  4. 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.
  5. 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.

Related

Environment

@solidjs/web built from next (d6a4a52f)
Node v24.19.0
OS macOS (darwin 25.6.0)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions