Skip to content

Commit fbe5bef

Browse files
fix(web): encrypt the no-JS flash cookie under the deployment secret (#3239)
The flash carries the submitted form input — whatever the user typed, passwords included — and rode as readable JSON in proxy logs, in the cookie jar at rest, and on every request until cleared. Both codec halves run only on the server, so the cookie is an opaque server-to-server channel: the payload is now AES-GCM encrypted, and without a key the flash is withheld entirely rather than sent plain (the no-JS post still redirects; dev builds warn once). The key derives, domain-separated (solid.flash.v1), from THE DEPLOYMENT SECRET — deliberately not a flash-specific key, so a future feature derives its own key from the same secret under its own domain string and consumers configure one value, ever. Resolution order: the new configureServerFunctionsServer({ secret }) option, then globalThis.__SOLID_SECRET__ — the internal contract the Solid vite plugin fills with a random value per build. No generated fallback: an ephemeral per-process key would silently lose flashes behind a load balancer. Decryption failure — tampered value, foreign key, rotated deployment — reads as no flash and never takes down the render. The envelope gains SameSite=Lax and Max-Age=60; the ceiling probe now measures the encrypted value the browser actually stores. encodeFlashCookie / decodeFlashCookie become async (WebCrypto); the router absorbs one await when it updates. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d0ca3a4 commit fbe5bef

11 files changed

Lines changed: 496 additions & 104 deletions

.changeset/encrypt-flash-cookie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Encrypt the no-JS flash cookie (#3239). The flash carries the submitted form input — whatever the user typed — so its payload is now AES-GCM encrypted under a key derived (domain-separated) from the deployment secret: `configureServerFunctionsServer({ secret })`, falling back to the `globalThis.__SOLID_SECRET__` value the Solid bundler plugin injects into server builds. With no secret configured the outcome is withheld rather than sent in the clear (the post still redirects; dev builds warn once). Decryption failure — a tampered cookie, a rotated secret — reads as "no flash". The cookie now also carries `SameSite=Lax` and `Max-Age=60`, and `encodeFlashCookie`/`decodeFlashCookie` are async.

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

Lines changed: 197 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,133 @@
1414
// browser without pulling this module — or the server-functions package at
1515
// all — in with it.
1616
//
17-
// The payload is plain JSON rather than the wire codec: it has to survive a
18-
// 4 KB cookie, and both halves here are synchronous while the codec is not.
17+
// The payload is JSON rather than the wire codec (it has to survive a 4 KB
18+
// cookie), and the JSON is AES-GCM ENCRYPTED before it becomes the cookie
19+
// value (#3239): the flash carries the submitted form input — whatever the
20+
// user typed, passwords included — and a plaintext cookie leaves that
21+
// readable in proxy logs, in the jar at rest, and on every request it rides
22+
// until cleared. Both codec halves run only on the server (the browser just
23+
// stores and returns the value), so the cookie is an opaque server-to-server
24+
// channel and encrypting it costs consumers nothing but the async signature.
1925

2026
import { FLASH_COOKIE, parseCookieHeader, serializeCookie } from "../../src/cookies.js";
2127

28+
const DEV = "_SOLID_DEV_" === true;
29+
30+
// ---------------------------------------------------------------------------
31+
// Key resolution (#3239). The AES key derives from THE DEPLOYMENT SECRET —
32+
// deliberately not a flash-specific key: the secret is the deployment-wide
33+
// concept, and any future feature that needs a key derives its own from the
34+
// same secret under its own domain string (see FLASH_KEY_DOMAIN below), so
35+
// consumers configure one value, ever. Resolution order:
36+
//
37+
// 1. `configureServerFunctionsServer({ secret })` — the explicit option.
38+
// 2. `globalThis.__SOLID_SECRET__` — the INTERNAL bundler contract: the
39+
// Solid vite plugin injects `globalThis.__SOLID_SECRET__ ??=
40+
// "<random-per-build>"` into the app's server entry, so every instance
41+
// of one deployment shares one secret with zero configuration. Injected
42+
// into server output only; not public API.
43+
// 3. Neither — there is no way to store the outcome confidentially, so the
44+
// flash is withheld entirely: the no-JS post still redirects cleanly,
45+
// only the outcome echo is missing, and dev builds say why once.
46+
//
47+
// The secret must be shared by every instance that can serve the redirect
48+
// that follows the 303 (an ephemeral per-process key would silently lose
49+
// flashes behind a load balancer), which is why there is no generated
50+
// fallback.
51+
let configuredSecret;
52+
53+
export function setFlashSecret(secret) {
54+
configuredSecret = secret;
55+
}
56+
57+
function resolveSecret() {
58+
return configuredSecret !== undefined ? configuredSecret : globalThis.__SOLID_SECRET__;
59+
}
60+
61+
// The flash key is a DOMAIN-SEPARATED derivation of the deployment secret:
62+
// SHA-256 over the domain string then the secret's UTF-8 bytes, imported as
63+
// raw AES-256-GCM key material. The digest normalizes arbitrary-length
64+
// secrets to the key size and keeps the secret itself out of the CryptoKey;
65+
// the domain prefix means a future feature deriving its own key from the
66+
// same secret (a different domain string) shares no key material with the
67+
// flash — one secret, per-purpose keys, never cross-decryptable.
68+
const FLASH_KEY_DOMAIN = "solid.flash.v1\0";
69+
70+
let cachedSecret;
71+
let cachedKey;
72+
73+
function resolveFlashKey() {
74+
const secret = resolveSecret();
75+
if (typeof secret !== "string" || secret.length === 0) return null;
76+
if (secret !== cachedSecret) {
77+
cachedSecret = secret;
78+
cachedKey = crypto.subtle
79+
.digest("SHA-256", new TextEncoder().encode(FLASH_KEY_DOMAIN + secret))
80+
.then(digest =>
81+
crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"])
82+
);
83+
}
84+
return cachedKey;
85+
}
86+
87+
let warnedMissingKey = false;
88+
function warnMissingKey() {
89+
if (!DEV || warnedMissingKey) return;
90+
warnedMissingKey = true;
91+
console.warn(
92+
"[solid] A no-JS form outcome was not flashed: the flash cookie is encrypted and no key " +
93+
"is configured. Set configureServerFunctionsServer({ secret }) — or build with the Solid " +
94+
"bundler plugin, which provides a per-deployment key automatically. The submission " +
95+
"committed and the redirect was served; only the outcome echo was withheld."
96+
);
97+
}
98+
99+
// Wire format, versioned for evolution: the cookie value is
100+
// `1.<base64url(iv || ciphertext || tag)>` — a leading format version, a
101+
// 12-byte random IV, then AES-GCM output (whose trailing 16 bytes are the
102+
// auth tag). base64url is cookie-safe as-is, so the value survives
103+
// serializeCookie's percent-encoding byte-for-byte.
104+
const FLASH_FORMAT_VERSION = "1.";
105+
const IV_BYTES = 12;
106+
const GCM_TAG_BYTES = 16;
107+
108+
function toBase64url(bytes) {
109+
let binary = "";
110+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
111+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
112+
}
113+
114+
function fromBase64url(text) {
115+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
116+
const bytes = new Uint8Array(binary.length);
117+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
118+
return bytes;
119+
}
120+
121+
async function encryptFlashValue(key, json) {
122+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
123+
const ciphertext = new Uint8Array(
124+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(json))
125+
);
126+
const packed = new Uint8Array(IV_BYTES + ciphertext.length);
127+
packed.set(iv);
128+
packed.set(ciphertext, IV_BYTES);
129+
return FLASH_FORMAT_VERSION + toBase64url(packed);
130+
}
131+
132+
async function decryptFlashValue(key, value) {
133+
if (!value.startsWith(FLASH_FORMAT_VERSION)) return;
134+
const packed = fromBase64url(value.slice(FLASH_FORMAT_VERSION.length));
135+
if (packed.length <= IV_BYTES + GCM_TAG_BYTES) return;
136+
const plain = await crypto.subtle.decrypt(
137+
{ name: "AES-GCM", iv: packed.subarray(0, IV_BYTES) },
138+
key,
139+
packed.subarray(IV_BYTES)
140+
);
141+
return new TextDecoder().decode(plain);
142+
}
143+
22144
/**
23145
* The outcome of a call made without the client runtime, as it rides the
24146
* flash cookie: what was submitted, where, and what came back. `result` and
@@ -80,33 +202,44 @@ function decodeInputValue(value) {
80202
* outcome belongs to; pass `thrown` when the call threw rather than
81203
* returned.
82204
*
83-
* The payload is JSON inside the cookie: `FormData` and `URLSearchParams`
205+
* The payload is JSON, AES-GCM encrypted into the cookie value (#3239; see
206+
* the key resolution notes above — without a configured key the flash is
207+
* withheld and this returns `null`): `FormData` and `URLSearchParams`
84208
* arguments are captured as entry pairs and revived on decode, and `File`
85209
* entries are dropped (they cannot ride a cookie). Outcomes larger than
86-
* the 4 KB cookie ceiling are degraded to fit rather than silently lost —
87-
* the input echo goes first, then the value is bounded — and arrive with
88-
* `truncated` set (#3137). When even the fully-degraded payload cannot
89-
* fit (a caller-chosen `url` alone past the ceiling), the flash is
90-
* REFUSED — `null`, no cookie — rather than truncated to a prefix that
91-
* would attach the outcome to a submission it does not identify (#3249);
92-
* the handler falls back to the plain redirect.
210+
* the 4 KB cookie ceiling — measured on the encrypted value the browser
211+
* stores — are degraded to fit rather than silently lost: the input echo
212+
* goes first, then the value is bounded, arriving with `truncated` set
213+
* (#3137). When even the fully-degraded payload cannot fit (a
214+
* caller-chosen `url` alone past the ceiling), the flash is REFUSED —
215+
* `null`, no cookie — rather than truncated to a prefix that would attach
216+
* the outcome to a submission it does not identify (#3249); the handler
217+
* falls back to the plain redirect.
93218
*/
94219
export function encodeFlashCookie(
95220
url: string,
96221
result: any,
97222
input: any[],
98223
thrown?: boolean
99-
): string | null;
224+
): Promise<string | null>;
100225

101226
/**
102227
* Encodes the outcome of a no-JS call as a Set-Cookie value, or `null`
103-
* when no storable cookie exists for it. `url` is the call's url (the
104-
* unbound function base — the request's pathname) so the integration can
105-
* tell which submission the outcome belongs to; `thrown` errors land on
106-
* `error`, returned values on `result`, mirroring the split a scripted
228+
* when no storable cookie exists for it — the outcome cannot fit the
229+
* cookie ceiling (#3249), or no encryption key is configured (#3239: the
230+
* payload carries form input and never rides plaintext; without a key the
231+
* flash is withheld and dev builds warn once). `url` is the call's url
232+
* (the unbound function base — the request's pathname) so the integration
233+
* can tell which submission the outcome belongs to; `thrown` errors land
234+
* on `error`, returned values on `result`, mirroring the split a scripted
107235
* call sees.
108236
*/
109-
export function encodeFlashCookie(url, result, input, thrown) {
237+
export async function encodeFlashCookie(url, result, input, thrown) {
238+
const key = resolveFlashKey();
239+
if (!key) {
240+
warnMissingKey();
241+
return null;
242+
}
110243
const isError = result instanceof Error;
111244
const payload = {
112245
url,
@@ -115,7 +248,7 @@ export function encodeFlashCookie(url, result, input, thrown) {
115248
thrown: !!thrown,
116249
input: input.map(encodeInputValue)
117250
};
118-
if (fitsCookie(payload)) return flashCookie(payload);
251+
if (fitsCookie(payload)) return flashCookie(payload, await key);
119252
// A cookie has a hard ceiling and no failure signal: past it the browser
120253
// discards the whole Set-Cookie — nothing in the response, nothing in the
121254
// console, nothing server-side — and the page after the redirect is
@@ -154,41 +287,70 @@ export function encodeFlashCookie(url, result, input, thrown) {
154287
// plain redirect — the navigation still lands, only the outcome echo is
155288
// withheld.
156289
if (!fitsCookie(payload)) return null;
157-
return flashCookie(payload);
290+
return flashCookie(payload, await key);
158291
}
159292

160-
function flashCookie(payload) {
161-
return serializeCookie(FLASH_COOKIE, JSON.stringify(payload), { secure: true, httpOnly: true });
293+
// The envelope (#3239): one-shot by design, so `Max-Age=60` bounds how long
294+
// an unconsumed outcome — an interrupted redirect, a closed tab — rests in
295+
// the jar; the consuming render is the very next navigation, milliseconds
296+
// away. `SameSite=Lax` over Strict deliberately: the flash must ride the
297+
// top-level GET that follows the 303 even when that navigation is
298+
// cross-site-initiated, so a stray flash is consumed (and cleared) rather
299+
// than lingering. Lax is not load-bearing for confidentiality — the value
300+
// is ciphertext — nor for CSRF: the origin gate refuses cross-site POSTs
301+
// before the no-JS handler is ever selected, so no flash exists for them.
302+
const FLASH_MAX_AGE_SECONDS = 60;
303+
304+
async function flashCookie(payload, key) {
305+
return serializeCookie(FLASH_COOKIE, await encryptFlashValue(key, JSON.stringify(payload)), {
306+
secure: true,
307+
httpOnly: true,
308+
sameSite: "lax",
309+
maxAge: FLASH_MAX_AGE_SECONDS
310+
});
162311
}
163312

164313
// The browser ceiling is 4096 bytes of `name=value` (RFC 6265bis §5.6);
165314
// RFC 6265 §6.1 states the same number but counts attributes too. 4000 for
166315
// the pair leaves headroom for the attributes under either reading.
167316
const COOKIE_PAIR_BUDGET = 4000;
168317

318+
// The ceiling is measured on what the browser stores: the ENCRYPTED value
319+
// (#3249 composed with #3239). AES-GCM ciphertext length equals plaintext
320+
// length, so the stored size is deterministic from the JSON's UTF-8 byte
321+
// count — version prefix, IV, auth tag, then unpadded base64url inflation —
322+
// and the degradation ladder can still probe fit cheaply on the plaintext
323+
// payload it is shrinking.
169324
function fitsCookie(payload) {
170-
return (
171-
FLASH_COOKIE.length + 1 + encodeURIComponent(JSON.stringify(payload)).length <=
172-
COOKIE_PAIR_BUDGET
173-
);
325+
const plaintextBytes = new TextEncoder().encode(JSON.stringify(payload)).length;
326+
const packedBytes = IV_BYTES + plaintextBytes + GCM_TAG_BYTES;
327+
const valueLength = FLASH_FORMAT_VERSION.length + Math.ceil((packedBytes * 4) / 3);
328+
return FLASH_COOKIE.length + 1 + valueLength <= COOKIE_PAIR_BUDGET;
174329
} /**
175330
* Decodes the flash cookie out of a request's `Cookie` header, for the
176331
* render that follows the redirect. Returns undefined when the cookie is
177332
* absent or unreadable — a malformed cookie never takes down the render,
178333
* and `clearFlashCookie` should be appended regardless.
179334
*/
180-
export function decodeFlashCookie(cookieHeader: string | null): FlashSubmission | undefined;
335+
export function decodeFlashCookie(
336+
cookieHeader: string | null
337+
): Promise<FlashSubmission | undefined>;
181338

182339
/**
183340
* Decodes the flash cookie out of a request's Cookie header. Returns
184-
* undefined when absent or unreadable — a malformed cookie must never take
185-
* down the render, and it is cleared either way.
341+
* undefined when absent or unreadable — a malformed, tampered, foreign-key
342+
* or key-less cookie must never take down the render (#3239: decryption
343+
* failure reads as "no flash"), and it is cleared either way.
186344
*/
187-
export function decodeFlashCookie(cookieHeader) {
345+
export async function decodeFlashCookie(cookieHeader) {
188346
const match = parseCookieHeader(cookieHeader)[FLASH_COOKIE];
189347
if (!match) return;
190348
try {
191-
const payload = JSON.parse(match);
349+
const key = resolveFlashKey();
350+
if (!key) return;
351+
const json = await decryptFlashValue(await key, match);
352+
if (json === undefined) return;
353+
const payload = JSON.parse(json);
192354
// Structural, not truthy: a well-formed cookie whose result is `""`,
193355
// `0`, `false` or `null` is a delivered outcome — a truthiness test here
194356
// discarded it after the encoder wrote it and the browser stored it, and
@@ -205,6 +367,11 @@ export function decodeFlashCookie(cookieHeader) {
205367
if (payload.truncated) submission.truncated = true;
206368
return submission;
207369
} catch (error) {
208-
console.error(error);
370+
// A cookie that fails to decrypt or parse is not an outcome: a tampered
371+
// value, a rotated deployment key, or plain garbage all read as "no
372+
// flash" — and the eager one-shot clear disposes of it either way. Noisy
373+
// only in dev; a key rotation must not error-log every affected request
374+
// in production.
375+
if (DEV) console.error(error);
209376
}
210377
}

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "../../src/response.js";
2222
import { COMPOSED_BODY_FRAMING, isHttpNavigationTarget } from "../../src/constants.js";
2323
import { RequestContext, commitEventResponse, getRequestEvent } from "../../src/server.js";
24-
import { encodeFlashCookie } from "./flash.js";
24+
import { encodeFlashCookie, setFlashSecret } from "./flash.js";
2525
import {
2626
BODY_FORMAT_HEADER,
2727
BodyFormat,
@@ -378,6 +378,25 @@ export interface ServerFunctionsServerConfig {
378378
* @default 1000
379379
*/
380380
maxArguments?: number;
381+
/**
382+
* The DEPLOYMENT SECRET: one value per deployment, from which any feature
383+
* that needs a key derives its own (domain-separated, so per-purpose keys
384+
* share no material). Today one feature does — the no-JS flash cookie
385+
* (#3239): the flash carries the submitted form input — whatever the user
386+
* typed — so its payload is always AES-GCM encrypted; it never rides the
387+
* wire or rests in the cookie jar as plaintext. Every instance that can
388+
* serve the render after a form post must share the secret (behind a load
389+
* balancer, a per-instance secret would silently lose outcomes), so there
390+
* is no generated fallback here: when this is not set, the secret falls
391+
* back to the one the Solid bundler plugin injects into the server build
392+
* (a fresh value per build), and with neither present the outcome is
393+
* simply not flashed — the form post still redirects cleanly, and dev
394+
* builds warn once. Any non-empty string works; rotating it (or
395+
* redeploying with the plugin's value) invalidates in-flight flashes,
396+
* which are 60-second one-shot cookies — the next render reads "no
397+
* flash".
398+
*/
399+
secret?: string;
381400
}
382401

383402
/**
@@ -594,7 +613,8 @@ export function configureServerFunctionsServer({
594613
csrf,
595614
codec,
596615
bodySizeLimit,
597-
maxArguments
616+
maxArguments,
617+
secret
598618
} = {}) {
599619
if (provideEvent !== undefined) config.provideEvent = provideEvent;
600620
if (wrapInvocation !== undefined) config.wrapInvocation = wrapInvocation;
@@ -608,6 +628,8 @@ export function configureServerFunctionsServer({
608628
if (codec !== undefined) configureServerFunctionsCodec(codec);
609629
if (bodySizeLimit !== undefined) config.bodySizeLimit = bodySizeLimit;
610630
if (maxArguments !== undefined) config.maxArguments = maxArguments;
631+
// the flash codec owns the key (flash.js) — the option just names it
632+
if (secret !== undefined) setFlashSecret(secret);
611633
}
612634

613635
// Named flight-data collectors, keyed by source id. The unnamed
@@ -2084,7 +2106,7 @@ function warnScripted304(functionId) {
20842106
*/
20852107
export function createNoJSHandler(
20862108
options?: NoJSHandlerOptions
2087-
): (result: unknown, request: Request, args: unknown[], thrown?: boolean) => Response;
2109+
): (result: unknown, request: Request, args: unknown[], thrown?: boolean) => Promise<Response>;
20882110

20892111
/**
20902112
* Builds the `handleNoJS` implementation for the no-JS form convention: a
@@ -2098,7 +2120,7 @@ export function createNoJSHandler(
20982120
* including direct HTTP ones.
20992121
*/
21002122
export function createNoJSHandler({ base = "" } = {}) {
2101-
return function handleNoJS(result, request, args, thrown) {
2123+
return async function handleNoJS(result, request, args, thrown) {
21022124
const url = new URL(request.url);
21032125
// an unusable referer (no-referrer policy, garbage) still beats leaving
21042126
// the browser sitting on the server function endpoint
@@ -2150,7 +2172,10 @@ export function createNoJSHandler({ base = "" } = {}) {
21502172
// and the argument parser's `?args` prepend already gives `args` that
21512173
// input shape here (#3239).
21522174
if (result !== undefined && !(result instanceof Response)) {
2153-
const flash = encodeFlashCookie(url.pathname, result, args, thrown);
2175+
// Encrypted (#3239), hence async; null when the outcome cannot fit
2176+
// (#3249) or no key is configured — either way the redirect goes out
2177+
// plain.
2178+
const flash = await encodeFlashCookie(url.pathname, result, args, thrown);
21542179
if (flash !== null) headers.append("Set-Cookie", flash);
21552180
}
21562181
return new Response(null, { status, headers });

0 commit comments

Comments
 (0)