Summary
A transformFlightResult policy that returns a Response it still holds — a memoized shell, a per-route singleton — has this request's Set-Cookie appended onto that object by the single-flight fold, permanently. The next caller to hit the same policy gets their own cookie plus every previous caller's, so one user's session cookie is served to the next, at status 200/303 with no error anywhere. foldFlightData writes the accumulated headers straight onto the value the hook handed back (packages/web/server-functions/src/server.ts:1403-1409) before anything copies it; the ownResponse copies added by #3155 both sit downstream of this write, so they faithfully carry the contamination forward instead of preventing it. Two symptoms, one write: the returned-outcome leg and the thrown-redirect leg — a reader chasing either lands here.
Stock Solid is not affected. See Why it matters for the honest reachability bound.
Reproduction
packages/web/repro-3155-fold.mjs, run with node repro-3155-fold.mjs from packages/web against the built bundle:
// Repro: the single-flight fold writes onto the Response transformFlightResult
// returned, before taking ownership of it.
import { AsyncLocalStorage } from "node:async_hooks";
import {
SINGLE_FLIGHT_HEADER,
handleServerFunctionRequest,
registerFlightDataSource,
registerServerFunction
} from "./server-functions/dist/server.js";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const BODY_FORMAT_HEADER = "X-Server-Function-Format";
const JSON_BODY_FORMAT = "8";
// The mutation: sets THIS tenant's session cookie, then redirects.
registerServerFunction(
"checkout",
async tenant =>
new Response(null, {
status: 303,
headers: { Location: `/tenant/${tenant}/orders`, "Set-Cookie": `session=${tenant}` }
})
);
// Any flight source, so the fold runs at all.
registerFlightDataSource("router", (_e, outcome) => ({ [outcome.targetUrl ?? "/"]: ["x"] }));
// SUBJECT: an integration that memoizes the shell it renders. Permitted by the
// hook's contract ("return a Response"); nothing says freshly built.
const memoized = new Response("<frame>region</frame>", {
status: 200,
headers: { "X-Content-Raw": "1", "Content-Type": "text/html" }
});
const memoizingTransform = async () => memoized;
// CONTROL: same integration, but builds a new Response per call — what Solid's
// own frames policy does.
const freshTransform = async () =>
new Response("<frame>region</frame>", {
status: 200,
headers: { "X-Content-Raw": "1", "Content-Type": "text/html" }
});
async function callAs(tenant, transformFlightResult) {
return handleServerFunctionRequest(
new Request("http://localhost/_server/data/checkout", {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
[BODY_FORMAT_HEADER]: JSON_BODY_FORMAT,
referer: `http://localhost/tenant/${tenant}/cart`,
[SINGLE_FLIGHT_HEADER]: "router"
},
body: JSON.stringify([tenant])
}),
{ transformFlightResult }
);
}
for (const [label, transform] of [
["SUBJECT memoizing transform", memoizingTransform],
["CONTROL fresh-per-call transform", freshTransform]
]) {
console.log(label);
for (const tenant of ["ALICE", "BOB", "CAROL"]) {
const res = await callAs(tenant, transform);
console.log(` ${tenant} receives Set-Cookie: ${JSON.stringify(res.headers.getSetCookie())}`);
}
}
console.log("integration's retained object, after those three requests:");
console.log(` Set-Cookie: ${JSON.stringify(memoized.headers.getSetCookie())}`);
console.log(` ${SINGLE_FLIGHT_HEADER}: ${JSON.stringify(memoized.headers.get(SINGLE_FLIGHT_HEADER))}`);
Measured output on the current tree:
SUBJECT memoizing transform
ALICE receives Set-Cookie: ["session=ALICE"]
BOB receives Set-Cookie: ["session=ALICE","session=BOB"]
CAROL receives Set-Cookie: ["session=ALICE","session=BOB","session=CAROL"]
CONTROL fresh-per-call transform
ALICE receives Set-Cookie: ["session=ALICE"]
BOB receives Set-Cookie: ["session=BOB"]
CAROL receives Set-Cookie: ["session=CAROL"]
integration's retained object, after those three requests:
Set-Cookie: ["session=ALICE","session=BOB","session=CAROL"]
X-Single-Flight: "router"
The CONTROL rows are the contrast: the identical policy, differing only in constructing its Response per call, is clean across all three tenants. Nothing about the mutation, the flight source, or the request differs between the two blocks.
The last two lines are the second half of the defect: the integration's own object is not merely read, it is written. It now carries three tenants' cookies and Solid's X-Single-Flight protocol header. That header is gap-filled (if (!transformed.headers.has(key))), so once stamped it is pinned — a later request whose fold names a different source list keeps the first request's list, and the client routes slices by that header.
With the same script run against a build where the fix is reverted and re-applied, the SUBJECT rows are the only thing that changes; the CONTROL rows are byte-identical in both.
Where
Line numbers are against f0f7531b (Merge pull request #3229 from solidjs/remove-patch-driver).
The defect — foldFlightData, packages/web/server-functions/src/server.ts:1399-1410:
if (transformed !== undefined) {
// Headers accumulated during the call (the mutation's cookies, an
// envelope's metadata) belong on whatever body carries the outcome.
for (const cookie of headers.getSetCookie()) transformed.headers.append("Set-Cookie", cookie); // :1403
headers.forEach((value, key) => {
if (key !== "set-cookie" && !transformed.headers.has(key)) {
transformed.headers.set(key, value); // :1406
}
});
return transformed; // :1409
}
transformed is whatever the hook returned. There is no copy between the hook's return and these writes.
Provenance. The write site and the transformFlightResult seam both arrived together in 71821959 — "Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders." (2026-08-25). At that point there was no ownership invariant anywhere in the transport, so the write was consistent with the rest of the file.
A neighbouring fix created the inconsistency. 08b4d1c6 — "fix: never mutate an application-held Response (#3155)" (2026-08-31) — established the invariant and introduced ownResponse (:3434), applying it at two sites:
:3420 — the handler funnel, enforceComposedHeaderInvariants(ownResponse(await dispatch())). This copy is taken after foldFlightData has already returned, so it copies a Response the fold has already written to. Its comment names exactly this harm — "one caller's session cookie served to the next, with no error anywhere" — for a value it can no longer protect.
:3388 — the thrown single-flight raw path, with the comment "ownership before the write — the fold may hand back a Response an integration hook caches (see ownResponse)". That comment is the runtime's own statement that a hook-returned Response may be application-held; the copy is simply placed one write too late. It still guards its own exit (the ERROR_HEADER set at :3389) and should stay.
So #3155 fixed the transport tail and left the fold — the one place inside that tail that writes to a foreign object before either copy is taken.
Why it matters
The realistic path: a multi-tenant app installs a transformFlightResult that renders an invalidated region and caches the rendered Response (a shell that does not vary per user is an obvious thing to memoize — the fold, after all, is what stamps the per-user headers on). Every mutation that folds flight data through that policy appends its Set-Cookie to the shared object; the response the next user receives carries the previous users' session cookies, and their browser sets them. That is a session-fixation / account-takeover shape, in band, with no error and nothing in a log.
The honest limits, and they are real:
- Stock Solid is not affected. The only in-tree policy,
frameTransformFlightResult, ends in frameFlightResponse (packages/web/frames/src/frame-sink.ts:2010), which constructs a new Response with a fresh ReadableStream on every call. Nothing shipped in this repository retains one.
- It requires a custom integration.
transformFlightResult is a documented public seam (server.ts:301-315, and both the @solidjs/web and @solidjs/universal changelogs describe it as "the typed transformFlightResult seam"), but an application only reaches this by writing one and reusing the object it returns.
- It is not remotely triggerable against stock Solid, and it is not a browser-reachable bug in isolation. There is no CVE-shaped claim here.
What makes it worth fixing anyway is that the hook's contract invites the reuse. The docstring says only "Return a Response to carry the outcome (call headers and cookies are copied onto it)" — "copied onto it" reads as if the copy is defensive. Nothing in the contract says the Response must be freshly constructed, and the runtime elsewhere (:3388) asserts in a comment that it may not be. Today an integration that reads the contract correctly gets a silent cross-user cookie leak.
Options
1. Take ownership before the first write, in the fold. One line: const owned = ownResponse(transformed); immediately after the undefined check, with the three writes and the return retargeted at owned.
- Cost: one extra
Response construction per single-flight call that transforms. ownResponse shares the body stream rather than duplicating it, so there is no buffering and no added latency on streamed frames.
- Behaviour change: for a policy that already builds fresh (i.e. everything in-tree), a copy is observationally identical — same status, statusText, headers, same body stream. The frames path is unchanged.
- Known limit, already documented on
ownResponse itself: a body-carrying singleton still "self-destructs on second use (Body is unusable)". Ownership fixes the header leak; it does not make a single stream servable twice. That failure is loud and was always there.
2. Copy at the fold's exit, after the writes — i.e. mirror what :3388 does today, inside foldFlightData. Cheaper to reason about as a diff, but it does not fix anything: the writes have already landed on the shared object, and the copy only carries the contamination onward. This is precisely the shape the current :3388 copy has, and the repro above is what it produces.
3. Leave the runtime alone; tighten the contract. Amend the transformFlightResult docstring to require a freshly constructed Response per call, and say plainly that Solid writes the call's headers onto whatever is returned. Zero runtime cost, no behaviour change. The trade-off is that misuse stays silent and its failure mode is another user's session — a class of bug documentation historically does not prevent.
4. Dev-mode detection. Track returned Responses in a WeakSet and warn on a second sighting under setServerFunctionsDev. Diagnostic only, dev-only cost, no production fix. Reasonable as an addition to 1 or 3; not a substitute.
5. Hoist ownership to the fold's callers. Copy at each foldFlightData call site instead. This needs a instanceof Response check at every site (the fold also returns plain envelopes and raw values), and it still leaves the fold's own writes unprotected in the interval — the write is inside the fold, so the copy has to be too.
Recommendation: option 1, with option 3's docstring amendment alongside it. In Solid's terms it is the minimal move: it introduces nothing, reuses the helper #3155 already added for this exact class, and makes the invariant that commit stated — the handler never writes to an object it did not construct — true everywhere rather than nearly everywhere. It also removes the standing oddity of :3388 carrying the right comment for the wrong position.
Two calls are genuinely the maintainer's, not mine, and I have not assumed either:
- Runtime fix vs. contract documentation (1 vs. 3). Solid may reasonably decide the seam is low-level enough that "return a fresh Response" is a documented obligation, not a runtime guarantee — the same way
handleNoJS results are trusted. If so, 3 alone is a defensible answer, and the :3388 comment should be reworded so it stops implying the runtime defends against this.
- Whether the header gap-fill deserves the same treatment as the cookies. Option 1 covers both because both write to the same object; if only the cookie leak is judged in scope, the
X-Single-Flight pinning shown in the repro's last line remains, and is worth a separate decision.
Regression test
packages/web/test/server/server-functions-flight-result-ownership.spec.tsx:
/**
* The fold must own a `transformFlightResult` Response before stamping it.
*
* `transformFlightResult` is the seam where an integration builds the
* single-flight body itself, and its contract is "return a Response" —
* nothing in it says the Response must be freshly constructed on every
* call. `foldFlightData` then appends the mutation's `Set-Cookie`s onto
* whatever came back and gap-fills the accumulated headers into it, writing
* through to an object the integration may still hold.
*
* That the runtime knows this is in-contract is visible in the thrown
* path's own fold tail, which copies before ITS write with the comment "the
* fold may hand back a Response an integration hook caches (see
* ownResponse)" — the same argument, applied on one leg and not the other.
* Copying there does not help: the fold's writes already landed on the
* shared object before the copy is taken.
*
* What leaks is the worst thing that can: session cookies. A transform that
* memoizes its rendered shell hands tenant A's `Set-Cookie` to tenant B and
* then hands both to tenant C, with no error anywhere — the same class of
* defect `ownResponse` was introduced for (#3155).
*
* Reachability, stated honestly: stock Solid is not affected. The frames
* policy (`frameTransformFlightResult`) constructs a fresh Response per
* call, so nothing in-tree caches one. These specs pin the contract for the
* seam as documented, which any integration may implement.
*
* Like the other server-function specs, these run against the built bundles
* (server-functions/dist/*, wired up in vite.config.server.mjs).
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
SINGLE_FLIGHT_HEADER,
handleServerFunctionRequest,
registerFlightDataSource,
registerServerFunction
} from "@solidjs/web/server-functions/server";
// not re-exported from the server entry; the wire name is the contract here
const BODY_FORMAT_HEADER = "X-Server-Function-Format";
const JSON_BODY_FORMAT = "8";
const RequestContext = Symbol.for("solid.RequestContext");
beforeAll(() => {
(globalThis as any)[RequestContext] = new AsyncLocalStorage();
});
afterAll(() => {
delete (globalThis as any)[RequestContext];
});
/**
* A transform that keeps the Response it built — the shape the contract
* permits and this spec is about. `retained` is the object the integration
* still holds after the request is over.
*/
function memoizingTransform() {
let retained: Response | undefined;
const transform = async () => {
if (!retained) {
retained = new Response("<frame>region</frame>", {
status: 200,
headers: { "X-Content-Raw": "1", "Content-Type": "text/html" }
});
}
return retained;
};
return {
transform,
get retained() {
return retained!;
}
};
}
/** One tenant's mutation: it sets that tenant's session cookie. */
async function callAs(id: string, tenant: string, transformFlightResult: any) {
return handleServerFunctionRequest(
new Request(`http://localhost/_server/data/${id}`, {
method: "POST",
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "server-function:test",
[BODY_FORMAT_HEADER]: JSON_BODY_FORMAT,
referer: `http://localhost/tenant/${tenant}/cart`,
[SINGLE_FLIGHT_HEADER]: "router"
},
body: JSON.stringify([tenant])
}),
{ transformFlightResult }
);
}
describe("single-flight fold owns the transformed response", () => {
it("does not stamp one tenant's session cookie onto the next tenant's response (returned outcome)", async () => {
registerServerFunction(
"sf-own-returned",
async (tenant: string) =>
new Response(null, {
status: 303,
headers: { Location: `/tenant/${tenant}/orders`, "Set-Cookie": `session=${tenant}` }
})
);
registerFlightDataSource("router", (_event: any, outcome: any) => ({
[outcome.targetUrl ?? "/"]: ["x"]
}));
const memo = memoizingTransform();
const cookies: Record<string, string[]> = {};
for (const tenant of ["ALICE", "BOB", "CAROL"]) {
const response = await callAs("sf-own-returned", tenant, memo.transform);
cookies[tenant] = response.headers.getSetCookie();
}
expect(cookies.ALICE).toEqual(["session=ALICE"]);
expect(cookies.BOB, `BOB's response carried ${JSON.stringify(cookies.BOB)}`).toEqual([
"session=BOB"
]);
expect(cookies.CAROL, `CAROL's response carried ${JSON.stringify(cookies.CAROL)}`).toEqual([
"session=CAROL"
]);
});
it("leaves the Response the integration retains unwritten (returned outcome)", async () => {
registerServerFunction(
"sf-own-retained",
async (tenant: string) =>
new Response(null, {
status: 303,
headers: { Location: `/tenant/${tenant}/orders`, "Set-Cookie": `session=${tenant}` }
})
);
registerFlightDataSource("router", () => ({ "/": ["x"] }));
const memo = memoizingTransform();
await callAs("sf-own-retained", "ALICE", memo.transform);
const stamped = memo.retained.headers.getSetCookie();
expect(
stamped,
`the fold wrote ${JSON.stringify(stamped)} onto the integration's own object`
).toEqual([]);
expect(
memo.retained.headers.get(SINGLE_FLIGHT_HEADER),
"the fold stamped its protocol header onto the integration's own object"
).toBe(null);
});
it("does not stamp one tenant's session cookie onto the next tenant's response (thrown outcome)", async () => {
// The thrown leg is the common single-flight shape (a mutation that
// throws a redirect) and the one whose tail already knows to copy — its
// copy is simply taken after the fold has written.
registerServerFunction("sf-own-thrown", async (tenant: string) => {
throw new Response(null, {
status: 303,
headers: { Location: `/tenant/${tenant}/orders`, "Set-Cookie": `session=${tenant}` }
});
});
registerFlightDataSource("router", (_event: any, outcome: any) => ({
[outcome.targetUrl ?? "/"]: ["x"]
}));
const memo = memoizingTransform();
const cookies: Record<string, string[]> = {};
for (const tenant of ["ALICE", "BOB", "CAROL"]) {
const response = await callAs("sf-own-thrown", tenant, memo.transform);
cookies[tenant] = response.headers.getSetCookie();
}
expect(cookies.ALICE).toEqual(["session=ALICE"]);
expect(cookies.BOB, `BOB's response carried ${JSON.stringify(cookies.BOB)}`).toEqual([
"session=BOB"
]);
expect(cookies.CAROL, `CAROL's response carried ${JSON.stringify(cookies.CAROL)}`).toEqual([
"session=CAROL"
]);
});
});
It goes red against any tree where foldFlightData writes to the hook's return value before copying it — all three cases fail on the unfixed build, e.g. AssertionError: BOB's response carried ["session=ALICE","session=BOB"]: expected [ 'session=ALICE', 'session=BOB' ] to deeply equal [ 'session=BOB' ] — and passes once ownership is taken ahead of the first append.
Summary
A
transformFlightResultpolicy that returns aResponseit still holds — a memoized shell, a per-route singleton — has this request'sSet-Cookieappended onto that object by the single-flight fold, permanently. The next caller to hit the same policy gets their own cookie plus every previous caller's, so one user's session cookie is served to the next, at status 200/303 with no error anywhere.foldFlightDatawrites the accumulated headers straight onto the value the hook handed back (packages/web/server-functions/src/server.ts:1403-1409) before anything copies it; theownResponsecopies added by #3155 both sit downstream of this write, so they faithfully carry the contamination forward instead of preventing it. Two symptoms, one write: the returned-outcome leg and the thrown-redirect leg — a reader chasing either lands here.Stock Solid is not affected. See Why it matters for the honest reachability bound.
Reproduction
packages/web/repro-3155-fold.mjs, run withnode repro-3155-fold.mjsfrompackages/webagainst the built bundle:Measured output on the current tree:
The CONTROL rows are the contrast: the identical policy, differing only in constructing its
Responseper call, is clean across all three tenants. Nothing about the mutation, the flight source, or the request differs between the two blocks.The last two lines are the second half of the defect: the integration's own object is not merely read, it is written. It now carries three tenants' cookies and Solid's
X-Single-Flightprotocol header. That header is gap-filled (if (!transformed.headers.has(key))), so once stamped it is pinned — a later request whose fold names a different source list keeps the first request's list, and the client routes slices by that header.With the same script run against a build where the fix is reverted and re-applied, the SUBJECT rows are the only thing that changes; the CONTROL rows are byte-identical in both.
Where
Line numbers are against
f0f7531b(Merge pull request #3229 from solidjs/remove-patch-driver).The defect —
foldFlightData,packages/web/server-functions/src/server.ts:1399-1410:transformedis whatever the hook returned. There is no copy between the hook's return and these writes.Provenance. The write site and the
transformFlightResultseam both arrived together in71821959— "Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders." (2026-08-25). At that point there was no ownership invariant anywhere in the transport, so the write was consistent with the rest of the file.A neighbouring fix created the inconsistency.
08b4d1c6— "fix: never mutate an application-held Response (#3155)" (2026-08-31) — established the invariant and introducedownResponse(:3434), applying it at two sites::3420— the handler funnel,enforceComposedHeaderInvariants(ownResponse(await dispatch())). This copy is taken afterfoldFlightDatahas already returned, so it copies a Response the fold has already written to. Its comment names exactly this harm — "one caller's session cookie served to the next, with no error anywhere" — for a value it can no longer protect.:3388— the thrown single-flight raw path, with the comment "ownership before the write — the fold may hand back a Response an integration hook caches (seeownResponse)". That comment is the runtime's own statement that a hook-returned Response may be application-held; the copy is simply placed one write too late. It still guards its own exit (theERROR_HEADERset at:3389) and should stay.So #3155 fixed the transport tail and left the fold — the one place inside that tail that writes to a foreign object before either copy is taken.
Why it matters
The realistic path: a multi-tenant app installs a
transformFlightResultthat renders an invalidated region and caches the renderedResponse(a shell that does not vary per user is an obvious thing to memoize — the fold, after all, is what stamps the per-user headers on). Every mutation that folds flight data through that policy appends itsSet-Cookieto the shared object; the response the next user receives carries the previous users' session cookies, and their browser sets them. That is a session-fixation / account-takeover shape, in band, with no error and nothing in a log.The honest limits, and they are real:
frameTransformFlightResult, ends inframeFlightResponse(packages/web/frames/src/frame-sink.ts:2010), which constructs anew Responsewith a freshReadableStreamon every call. Nothing shipped in this repository retains one.transformFlightResultis a documented public seam (server.ts:301-315, and both the@solidjs/weband@solidjs/universalchangelogs describe it as "the typedtransformFlightResultseam"), but an application only reaches this by writing one and reusing the object it returns.What makes it worth fixing anyway is that the hook's contract invites the reuse. The docstring says only "Return a
Responseto carry the outcome (call headers and cookies are copied onto it)" — "copied onto it" reads as if the copy is defensive. Nothing in the contract says theResponsemust be freshly constructed, and the runtime elsewhere (:3388) asserts in a comment that it may not be. Today an integration that reads the contract correctly gets a silent cross-user cookie leak.Options
1. Take ownership before the first write, in the fold. One line:
const owned = ownResponse(transformed);immediately after theundefinedcheck, with the three writes and thereturnretargeted atowned.Responseconstruction per single-flight call that transforms.ownResponseshares the body stream rather than duplicating it, so there is no buffering and no added latency on streamed frames.ownResponseitself: a body-carrying singleton still "self-destructs on second use (Body is unusable)". Ownership fixes the header leak; it does not make a single stream servable twice. That failure is loud and was always there.2. Copy at the fold's exit, after the writes — i.e. mirror what
:3388does today, insidefoldFlightData. Cheaper to reason about as a diff, but it does not fix anything: the writes have already landed on the shared object, and the copy only carries the contamination onward. This is precisely the shape the current:3388copy has, and the repro above is what it produces.3. Leave the runtime alone; tighten the contract. Amend the
transformFlightResultdocstring to require a freshly constructedResponseper call, and say plainly that Solid writes the call's headers onto whatever is returned. Zero runtime cost, no behaviour change. The trade-off is that misuse stays silent and its failure mode is another user's session — a class of bug documentation historically does not prevent.4. Dev-mode detection. Track returned Responses in a
WeakSetand warn on a second sighting undersetServerFunctionsDev. Diagnostic only, dev-only cost, no production fix. Reasonable as an addition to 1 or 3; not a substitute.5. Hoist ownership to the fold's callers. Copy at each
foldFlightDatacall site instead. This needs ainstanceof Responsecheck at every site (the fold also returns plain envelopes and raw values), and it still leaves the fold's own writes unprotected in the interval — the write is inside the fold, so the copy has to be too.Recommendation: option 1, with option 3's docstring amendment alongside it. In Solid's terms it is the minimal move: it introduces nothing, reuses the helper #3155 already added for this exact class, and makes the invariant that commit stated — the handler never writes to an object it did not construct — true everywhere rather than nearly everywhere. It also removes the standing oddity of
:3388carrying the right comment for the wrong position.Two calls are genuinely the maintainer's, not mine, and I have not assumed either:
handleNoJSresults are trusted. If so, 3 alone is a defensible answer, and the:3388comment should be reworded so it stops implying the runtime defends against this.X-Single-Flightpinning shown in the repro's last line remains, and is worth a separate decision.Regression test
packages/web/test/server/server-functions-flight-result-ownership.spec.tsx:It goes red against any tree where
foldFlightDatawrites to the hook's return value before copying it — all three cases fail on the unfixed build, e.g.AssertionError: BOB's response carried ["session=ALICE","session=BOB"]: expected [ 'session=ALICE', 'session=BOB' ] to deeply equal [ 'session=BOB' ]— and passes once ownership is taken ahead of the first append.