Skip to content

Commit ecfee20

Browse files
fix(web): bound the flash cookie and validate unservable cookie shapes (#3137, #3138)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 93adc02 commit ecfee20

5 files changed

Lines changed: 251 additions & 5 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Two cookie fixes. The no-JS flash cookie now degrades instead of vanishing
6+
when an outcome exceeds the browser's 4 KB cookie ceiling (#3137): past it
7+
the whole Set-Cookie was silently discarded — no error anywhere, and the
8+
page after the redirect looked like nothing was submitted, inviting the
9+
retry that writes twice. The encoder drops the input echo first, then
10+
bounds the value itself (a string keeps the longest prefix that fits,
11+
structured results reduce to the outcome flag), and the submission arrives
12+
with `truncated` set so integrations can say "succeeded, result too large
13+
to display". And `serializeCookie` now refuses in dev the shapes every
14+
browser silently rejects on arrival (#3138): `__Host-`/`__Secure-` prefix
15+
requirements and `SameSite=None`/`Partitioned` without `Secure` — each one
16+
attribute away from a cookie that never comes back, with login-shaped
17+
consequences. The validation compiles out of production builds. CHIPS
18+
`partitioned` is also supported now, so partitioned third-party cookies no
19+
longer require hand-building the header string.

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

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export interface FlashSubmission {
3434
result?: any;
3535
/** The thrown value, when the call threw. */
3636
error?: any;
37+
/**
38+
* Set when the outcome was too large for the cookie's 4 KB ceiling and
39+
* was degraded to fit (#3137): the input echo is dropped, and `result` /
40+
* `error` may carry a bounded prefix — or the bare outcome flag `true` —
41+
* rather than the full value. The submission still says what happened
42+
* and where; integrations should render it as "succeeded (result too
43+
* large to display)" rather than replaying the value.
44+
*/
45+
truncated?: boolean;
3746
}
3847

3948
// Form payloads have no JSON encoding, so entries are captured as pair
@@ -65,9 +74,10 @@ function decodeInputValue(value) {
6574
*
6675
* The payload is JSON inside the cookie: `FormData` and `URLSearchParams`
6776
* arguments are captured as entry pairs and revived on decode, and `File`
68-
* entries are dropped (they cannot ride a cookie). Keep in mind the 4 KB
69-
* cookie budget — outcomes larger than that will not survive the round
70-
* trip.
77+
* entries are dropped (they cannot ride a cookie). Outcomes larger than
78+
* the 4 KB cookie ceiling are degraded to fit rather than silently lost —
79+
* the input echo goes first, then the value is bounded — and arrive with
80+
* `truncated` set (#3137).
7181
*/
7282
export function encodeFlashCookie(url: string, result: any, input: any[], thrown?: boolean): string;
7383

@@ -87,7 +97,50 @@ export function encodeFlashCookie(url, result, input, thrown) {
8797
thrown: !!thrown,
8898
input: input.map(encodeInputValue)
8999
};
100+
if (fitsCookie(payload)) return flashCookie(payload);
101+
// A cookie has a hard ceiling and no failure signal: past it the browser
102+
// discards the whole Set-Cookie — nothing in the response, nothing in the
103+
// console, nothing server-side — and the page after the redirect is
104+
// indistinguishable from one where nothing was submitted. The mutation
105+
// has already COMMITTED by the time this encodes, so the outcome must
106+
// degrade rather than vanish (#3137): the natural response to a missing
107+
// confirmation is to retry, and for a non-idempotent handler that is the
108+
// second write. Ladder: drop the input echo (usually the bulk), then
109+
// bound the value itself — a string keeps the longest prefix that fits
110+
// (halving, because percent-encoding inflates unevenly), anything
111+
// structured has no partial JSON and reduces to the outcome flag `true`.
112+
// `url` and the error/thrown flags always survive: what happened, and to
113+
// which submission, is the part that must not be lost.
114+
payload.truncated = true;
115+
payload.input = [];
116+
if (!fitsCookie(payload)) {
117+
if (typeof payload.result === "string") {
118+
let prefix = payload.result;
119+
while (prefix.length > 0 && !fitsCookie({ ...payload, result: prefix })) {
120+
prefix = prefix.slice(0, prefix.length >> 1);
121+
}
122+
payload.result = prefix.length > 0 ? prefix : true;
123+
} else {
124+
payload.result = true;
125+
}
126+
}
127+
return flashCookie(payload);
128+
}
129+
130+
function flashCookie(payload) {
90131
return serializeCookie(FLASH_COOKIE, JSON.stringify(payload), { secure: true, httpOnly: true });
132+
}
133+
134+
// The browser ceiling is 4096 bytes of `name=value` (RFC 6265bis §5.6);
135+
// RFC 6265 §6.1 states the same number but counts attributes too. 4000 for
136+
// the pair leaves headroom for the attributes under either reading.
137+
const COOKIE_PAIR_BUDGET = 4000;
138+
139+
function fitsCookie(payload) {
140+
return (
141+
FLASH_COOKIE.length + 1 + encodeURIComponent(JSON.stringify(payload)).length <=
142+
COOKIE_PAIR_BUDGET
143+
);
91144
} /**
92145
* Decodes the flash cookie out of a request's `Cookie` header, for the
93146
* render that follows the redirect. Returns undefined when the cookie is
@@ -108,12 +161,14 @@ export function decodeFlashCookie(cookieHeader) {
108161
const payload = JSON.parse(match);
109162
if (!payload || !payload.result) return;
110163
const result = payload.error ? new Error(payload.result) : payload.result;
111-
return {
164+
const submission = {
112165
input: Array.isArray(payload.input) ? payload.input.map(decodeInputValue) : [],
113166
url: payload.url,
114167
result: payload.thrown ? undefined : result,
115168
error: payload.thrown ? result : undefined
116169
};
170+
if (payload.truncated) submission.truncated = true;
171+
return submission;
117172
} catch (error) {
118173
console.error(error);
119174
}

packages/web/src/cookies.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface CookieOptions {
3737
secure?: boolean;
3838
/** Cookie `SameSite` attribute, any case. */
3939
sameSite?: "lax" | "strict" | "none" | "Lax" | "Strict" | "None";
40+
/**
41+
* Emit the `Partitioned` attribute (CHIPS): a third-party cookie keyed to
42+
* the top-level site it was set under — the only third-party cookie that
43+
* keeps working as browsers finish removing the rest. Requires `secure`.
44+
*/
45+
partitioned?: boolean;
4046
}
4147

4248
/**
@@ -87,21 +93,65 @@ function decodeSafe(text: string): string {
8793
* value, options))`, which every head materialization path carries to the
8894
* wire entry-by-entry.
8995
*/
96+
// Build-variant dev flag: the string literal is replaced at build time, so
97+
// prod minification drops the guarded branch and the assert behind it — the
98+
// validation costs no production bytes.
99+
const DEV = "_SOLID_DEV_" as unknown as boolean;
100+
90101
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
102+
if (DEV) assertServableCookie(name, options);
91103
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
92104
cookie += `; Path=${options.path === undefined ? "/" : options.path}`;
93105
if (options.domain) cookie += `; Domain=${options.domain}`;
94106
if (options.maxAge !== undefined) cookie += `; Max-Age=${Math.trunc(options.maxAge)}`;
95107
if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
96108
if (options.httpOnly) cookie += "; HttpOnly";
97109
if (options.secure) cookie += "; Secure";
110+
if (options.partitioned) cookie += "; Partitioned";
98111
if (options.sameSite) {
99112
const sameSite = options.sameSite.toLowerCase();
100113
cookie += `; SameSite=${sameSite === "none" ? "None" : sameSite === "strict" ? "Strict" : "Lax"}`;
101114
}
102115
return cookie;
103116
}
104117

118+
// Shapes the browser enforces ON ARRIVAL and rejects with no trace — no
119+
// response error, no console line, nothing server-side; the cookie simply
120+
// never comes back (#3138). For a `__Host-` session cookie that reads as
121+
// "the user is never logged in", and the option that breaks it is the one
122+
// you would naturally set (`path: "/admin"` — a sensible-looking scoping
123+
// that silently disables login). Dev refuses to emit them, which is the
124+
// only place the author is ever told; production emits exactly what it is
125+
// handed — the check compiles out, no bytes change. The prefix match is
126+
// case-insensitive, as browsers apply it (RFC 6265bis §4.1.3).
127+
function assertServableCookie(name: string, options: CookieOptions): void {
128+
const reject = (reason: string) => {
129+
throw new Error(
130+
`serializeCookie: every browser silently rejects this cookie — ${reason}. ` +
131+
`It would never come back on a request, with no error anywhere.`
132+
);
133+
};
134+
const lower = name.toLowerCase();
135+
if (lower.startsWith("__host-")) {
136+
if (!options.secure) reject(`the __Host- prefix on \`${name}\` requires \`secure: true\``);
137+
if (options.path !== undefined && options.path !== "/")
138+
reject(
139+
`the __Host- prefix on \`${name}\` requires \`Path=/\` (got \`${options.path}\`) — ` +
140+
`host-locking is the prefix's whole contract, so it cannot be path-scoped`
141+
);
142+
if (options.domain)
143+
reject(`the __Host- prefix on \`${name}\` forbids \`Domain\` (got \`${options.domain}\`)`);
144+
} else if (lower.startsWith("__secure-") && !options.secure) {
145+
reject(`the __Secure- prefix on \`${name}\` requires \`secure: true\``);
146+
}
147+
if (options.sameSite && options.sameSite.toLowerCase() === "none" && !options.secure) {
148+
reject("`SameSite=None` requires `secure: true`");
149+
}
150+
if (options.partitioned && !options.secure) {
151+
reject("`Partitioned` requires `secure: true`");
152+
}
153+
}
154+
105155
// ---- the flash cookie's isomorphic half ----
106156
//
107157
// Cookie carrying the outcome of a server function call made without the

packages/web/test/runtime/cookies.spec.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,45 @@ describe("serializeCookie", () => {
9090
});
9191

9292
it("normalizes sameSite casing", () => {
93-
expect(serializeCookie("a", "b", { sameSite: "none" })).toBe("a=b; Path=/; SameSite=None");
93+
expect(serializeCookie("a", "b", { sameSite: "none", secure: true })).toBe(
94+
"a=b; Path=/; Secure; SameSite=None"
95+
);
9496
expect(serializeCookie("a", "b", { sameSite: "Strict" })).toBe("a=b; Path=/; SameSite=Strict");
9597
});
9698

99+
it("emits Partitioned (CHIPS) when asked", () => {
100+
expect(serializeCookie("widget", "v", { secure: true, partitioned: true })).toBe(
101+
"widget=v; Path=/; Secure; Partitioned"
102+
);
103+
});
104+
105+
it("refuses in dev the shapes every browser silently rejects (#3138)", () => {
106+
// The rejection happens on ARRIVAL and leaves no trace anywhere — the
107+
// cookie simply never comes back. Dev is the only place the author can
108+
// be told; each of these is one attribute away from a stored cookie.
109+
// __Host-: requires Secure, Path=/, no Domain — and the option that
110+
// breaks it is the one you would naturally set (`path: "/admin"` on a
111+
// session cookie silently disables login).
112+
expect(() => serializeCookie("__Host-sid", "v", { path: "/orders", secure: true })).toThrow(
113+
/__Host-/
114+
);
115+
expect(() =>
116+
serializeCookie("__Host-sid", "v", { domain: "example.com", secure: true })
117+
).toThrow(/__Host-/);
118+
expect(() => serializeCookie("__Host-sid", "v", {})).toThrow(/__Host-/);
119+
// prefixes are matched case-insensitively, as browsers apply them
120+
expect(() => serializeCookie("__host-sid", "v", {})).toThrow(/__Host-/);
121+
expect(() => serializeCookie("__Secure-tok", "v", {})).toThrow(/__Secure-/);
122+
expect(() => serializeCookie("cross", "v", { sameSite: "none" })).toThrow(/SameSite=None/);
123+
expect(() => serializeCookie("widget", "v", { partitioned: true })).toThrow(/Partitioned/);
124+
125+
// the controls: done right, each shape emits
126+
expect(serializeCookie("__Host-ok", "v", { secure: true })).toBe("__Host-ok=v; Path=/; Secure");
127+
expect(serializeCookie("__Secure-ok", "v", { secure: true })).toBe(
128+
"__Secure-ok=v; Path=/; Secure"
129+
);
130+
});
131+
97132
it("percent-encodes name and value so any string round-trips", () => {
98133
const value = "sp ace;semi=eq,comma✓";
99134
const serialized = serializeCookie("na;me", value);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* The flash cookie's size bound (#3137). A cookie has a hard ceiling and no
3+
* failure signal: past ~4096 bytes of name=value the browser discards the
4+
* whole Set-Cookie — nothing in the response, nothing in the console,
5+
* nothing server-side — and the page after the no-JS redirect is
6+
* indistinguishable from one where nothing was submitted. The mutation has
7+
* already COMMITTED by then, so a vanished outcome invites the retry that
8+
* writes twice. The encoder degrades instead of vanishing: the input echo
9+
* goes first, then the value is bounded, and `url` plus the error/thrown
10+
* flags always survive, arriving with `truncated` set.
11+
*
12+
* Runs against the built bundles like the other server-function specs.
13+
*/
14+
import { describe, expect, it } from "vitest";
15+
import { decodeFlashCookie, encodeFlashCookie } from "@solidjs/web/server-functions/server";
16+
17+
// what the browser stores: the name=value pair, before the attributes
18+
function pairOf(setCookie: string) {
19+
return setCookie.slice(0, setCookie.indexOf("; "));
20+
}
21+
22+
function roundTrip(setCookie: string) {
23+
return decodeFlashCookie(pairOf(setCookie));
24+
}
25+
26+
describe("the flash cookie stays under the browser's ceiling", () => {
27+
it("passes a small outcome through whole, untruncated", () => {
28+
const form = new FormData();
29+
form.set("sku", "A-1");
30+
const cookie = encodeFlashCookie("/checkout", { receipt: "RCPT-1" }, [form]);
31+
const submission = roundTrip(cookie)!;
32+
expect(submission.url).toBe("/checkout");
33+
expect(submission.result).toEqual({ receipt: "RCPT-1" });
34+
expect(submission.truncated).toBeUndefined();
35+
expect((submission.input[0] as FormData).get("sku")).toBe("A-1");
36+
});
37+
38+
it("drops the input echo first, keeping a result that still fits", () => {
39+
const form = new FormData();
40+
form.set("note", "x".repeat(8000)); // the submission is the bulk
41+
const cookie = encodeFlashCookie("/save", { id: 7, ok: true }, [form]);
42+
expect(pairOf(cookie).length).toBeLessThanOrEqual(4096);
43+
const submission = roundTrip(cookie)!;
44+
expect(submission.result).toEqual({ id: 7, ok: true }); // the answer survives whole
45+
expect(submission.input).toEqual([]); // the echo paid for it
46+
expect(submission.truncated).toBe(true);
47+
});
48+
49+
it("reduces a structured result past the ceiling to the outcome flag", () => {
50+
// ~200 rows of the issue's shape — 29 was already past the ceiling
51+
const rows = Array.from({ length: 200 }, (_, i) => ({
52+
id: i,
53+
sku: `SKU-${i}`,
54+
name: `Product ${i}`,
55+
price: 19.99,
56+
note: "restocked"
57+
}));
58+
const cookie = encodeFlashCookie("/bulk-save", rows, []);
59+
expect(pairOf(cookie).length).toBeLessThanOrEqual(4096);
60+
const submission = roundTrip(cookie)!;
61+
// structured JSON has no partial spelling: what survives is THAT it
62+
// happened and where — the part whose loss causes the double-submit
63+
expect(submission.url).toBe("/bulk-save");
64+
expect(submission.result).toBe(true);
65+
expect(submission.truncated).toBe(true);
66+
});
67+
68+
it("keeps the longest prefix of a string result that fits", () => {
69+
const cookie = encodeFlashCookie("/report", "line ".repeat(4000), []);
70+
expect(pairOf(cookie).length).toBeLessThanOrEqual(4096);
71+
const submission = roundTrip(cookie)!;
72+
expect(typeof submission.result).toBe("string");
73+
expect(submission.result.startsWith("line line ")).toBe(true);
74+
expect(submission.truncated).toBe(true);
75+
});
76+
77+
it("a thrown outcome stays an error with a bounded message", () => {
78+
const failure = new Error("constraint violated: " + "detail ".repeat(3000));
79+
const cookie = encodeFlashCookie("/charge", failure, [], true);
80+
expect(pairOf(cookie).length).toBeLessThanOrEqual(4096);
81+
const submission = roundTrip(cookie)!;
82+
expect(submission.error).toBeInstanceOf(Error);
83+
expect((submission.error as Error).message.startsWith("constraint violated:")).toBe(true);
84+
expect(submission.result).toBeUndefined();
85+
expect(submission.truncated).toBe(true);
86+
});
87+
});

0 commit comments

Comments
 (0)