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
2026import { 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 */
94219export 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.
167316const 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.
169324function 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}
0 commit comments