From c16985ac6262282de85c8a0b951127c722c6b8ba Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 3 Sep 2026 15:15:23 -0700 Subject: [PATCH 1/2] feat: inject the deployment secret into server builds (solidjs/solid#3239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's no-JS flash cookie is AES-GCM encrypted as of @solidjs/web's #3239 fix — it carries the submitted form input, so without a key the outcome is withheld entirely. This provides the key's secret with zero configuration through the internal globalThis.__SOLID_SECRET__ ??= contract: generated once per plugin instance via node:crypto randomBytes, emitted as the first statement of the generated server-function handler module, which every dispatch surface loads and the generated SSR handler imports at module load — so the secret is in place for both the flash's encode (the form POST) and its decode (the render that follows the redirect). Per plugin instance means a production build bakes one value into the emitted server chunk — every instance of that deployment shares it, which the runtime requires (a per-process value would silently lose flashes behind a load balancer) — and a dev session holds one for its lifetime; a restart invalidates in-flight flashes, which are 60-second one-shot cookies. The handler module is hard-gated server-only, so the secret never reaches the client graph, and ??= keeps an explicit configureServerFunctionsServer({ secret }) — or an outer harness's injected value — authoritative. Co-authored-by: Cursor --- .changeset/inject-deployment-secret.md | 5 +++++ src/server-functions/index.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 .changeset/inject-deployment-secret.md diff --git a/.changeset/inject-deployment-secret.md b/.changeset/inject-deployment-secret.md new file mode 100644 index 0000000..c968c3c --- /dev/null +++ b/.changeset/inject-deployment-secret.md @@ -0,0 +1,5 @@ +--- +"@solidjs/vite-plugin": patch +--- + +Provide the deployment secret to server builds (solidjs/solid#3239): the generated server-function handler module now leads with `globalThis.__SOLID_SECRET__ ??= ""`, giving the runtime's encrypted no-JS flash cookie a key with zero configuration. One value is generated per plugin instance, so a production build bakes a single secret into the emitted server chunk (shared by every instance of that deployment) and a dev session holds one for its lifetime. Server output only — the handler module is already hard-gated against client graphs — and an explicit `configureServerFunctionsServer({ secret })` still outranks it. diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index e51701b..28aae5d 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -8,6 +8,7 @@ // conditions resolve the right half per environment. Any runtime satisfying // that contract can be swapped in through `options.runtime` (SolidStart's, // or your own). +import { randomBytes } from 'crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; import { @@ -394,6 +395,23 @@ export function serverFunctions( }; let currentServer: ViteDevServer | undefined; + // THE DEPLOYMENT SECRET (solidjs/solid#3239): the runtime's flash cookie + // — the no-JS form outcome — carries the submitted input, so it is + // AES-GCM encrypted under a key derived from a deployment-wide secret, + // and without one the outcome is withheld entirely. This plugin provides + // that secret with zero configuration through the internal + // `globalThis.__SOLID_SECRET__ ??=` contract: generated once per plugin + // instance, so a production build bakes one value into the emitted server + // chunk — every instance of that deployment shares it (a per-process + // value would silently lose flashes behind a load balancer) — and a dev + // session holds one for its lifetime (a restart invalidates in-flight + // flashes, which are 60-second one-shot cookies; the next render just + // reads "no flash"). Server output only, never the client graph. The + // `??=` keeps an explicit `configureServerFunctionsServer({ secret })` — + // or a value injected by an outer harness — authoritative. + const deploymentSecret = randomBytes(32).toString('hex'); + const deploymentSecretSnippet = `globalThis.__SOLID_SECRET__ ??= ${JSON.stringify(deploymentSecret)};`; + const clientOptions: Pick = { directive, definitions: { @@ -457,6 +475,14 @@ export function serverFunctions( // import is only emitted when the option is on, so disabled setups keep // a server-component-free graph. return [ + // The deployment secret. Imports are hoisted above it, but nothing + // reads the global at module evaluation — the runtime resolves it + // lazily per encode/decode — so leading textually is just the honest + // placement. This module is loaded before any dispatch on both + // surfaces, and the generated SSR handler imports it at module load, + // so the secret is in place for the flash's encode (the form POST) + // and its decode (the render that follows the redirect) alike. + deploymentSecretSnippet, // The user's `configure` module comes first: a side-effect import in // the handler graph, evaluated before any dispatch on both surfaces // (dev middleware and prod handler) and bundled into the handler From 9e17922676d81886d94b5790aba7d07bfbf535bb Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 8 Sep 2026 10:28:08 -0700 Subject: [PATCH 2/2] test: no-JS flash round-trip under the injected deployment secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the no-JS server-function convention end to end in the middleware suite (mw-dev and mw-prod): a form navigation POST to a server function's bare address answers 303 back to the referrer with the encrypted flash cookie (`flash=1.…`, Max-Age=60, SameSite=Lax, HttpOnly), the render that follows decrypts it — src/setup.tsx plays the integration's half via decodeFlashCookie and surfaces url/result/input as a marker — and clears the one-shot cookie. Against @solidjs/web 2.0.0-rc.7 without the deployment secret the redirect goes out plain (no Set-Cookie) and the render sees no flash — 8 of the 12 new assertions fail; with the `globalThis.__SOLID_SECRET__ ??=` snippet they pass. The POST goes through node:http rather than fetch: undici treats the Sec-Fetch-* names as forbidden request headers and sends its own `Sec-Fetch-Mode: cors`, which the runtime rightly refuses (400) as a scripted call to the bare address. Co-authored-by: Cursor --- examples/start-ssr/src/setup.tsx | 31 ++++++++ examples/start-ssr/test/run.mjs | 120 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/examples/start-ssr/src/setup.tsx b/examples/start-ssr/src/setup.tsx index b9447a6..ef26771 100644 --- a/examples/start-ssr/src/setup.tsx +++ b/examples/start-ssr/src/setup.tsx @@ -5,8 +5,20 @@ // place. This fake awaits real async work, proves ordering (the middleware // chain already decorated `locals.user`), and counts invocations so the // harness can assert the hook runs per request, not once per module. +// +// It also plays the integration's half of the no-JS server-function +// convention (solidjs/solid#3239): a form posted without the client runtime +// redirects back here with its outcome riding an encrypted one-shot flash +// cookie. The hook decodes it — under the deployment secret the plugin +// injects into the handler graph — surfaces it as a marker for the e2e, and +// clears the cookie so the next render reads "no flash". import type { Component } from 'solid-js'; import type { RequestEvent } from '@solidjs/web'; +import { + clearFlashCookie, + decodeFlashCookie, + hasFlashCookie, +} from '@solidjs/web/server-functions/server'; let invocations = 0; @@ -17,9 +29,28 @@ export default async function setup(event: RequestEvent, App: Component) { const seq = ++invocations; const pathname = new URL(event.request.url).pathname; const user = String((event.locals as Record).user ?? 'anonymous'); + const cookieHeader = event.request.headers.get('cookie'); + // One-shot: cleared whether or not it decodes (a tampered or stale cookie + // reads as "no flash" and is disposed of the same way). + if (hasFlashCookie(cookieHeader)) { + event.response.headers.append('set-cookie', clearFlashCookie()); + } + const flash = await decodeFlashCookie(cookieHeader); + let flashMarker = ''; + if (flash) { + // A urlencoded post arrives as URLSearchParams, a multipart one as + // FormData; either way the submitted field must survive the cookie. + const input = flash.input[0]; + const submitted = + input instanceof FormData || input instanceof URLSearchParams + ? String(input.get('name')) + : `unexpected-input(${input?.constructor?.name ?? typeof input})`; + flashMarker = `flash:${flash.url}:${String(flash.result)}:${submitted}`; + } return () => ( <>

{`setup:${pathname}:${user}:${seq}`}

+ {flashMarker ?

{flashMarker}

: null} ); diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 5b16dda..c924ef2 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -224,6 +224,34 @@ async function fetchStreamed(url) { return { status: res.status, headers: res.headers, chunks, html }; } +/** + * A request with EXACTLY the headers given. `fetch` (undici) treats the + * `Sec-Fetch-*` names as forbidden request headers — it drops the caller's + * and sends its own `Sec-Fetch-Mode: cors` — so a browser form navigation + * (`Sec-Fetch-Mode: navigate`, the gate of the no-JS server-function + * convention) cannot be imitated through it. Never follows redirects; + * `setCookies` is the raw multi-valued `Set-Cookie`. + */ +function rawRequest(url, { method = 'GET', headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const req = http.request(url, { method, headers }, (res) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (text += chunk)); + res.on('end', () => + resolve({ + status: res.statusCode, + headers: res.headers, + setCookies: res.headers['set-cookie'] ?? [], + text, + }), + ); + }); + req.on('error', reject); + req.end(body); + }); +} + // --------------------------------------------------------------------------- // CDP driver // --------------------------------------------------------------------------- @@ -2697,6 +2725,98 @@ async function runMiddlewareChecksOverHttp(mode, origin, functionId) { !!first && !!second && second.seq > first.seq, `seq ${first?.seq} then ${second?.seq}`, ); + + // ---- The no-JS server-function convention, end to end ------------------ + // A browser form posted to a server function's bare address without the + // client runtime cannot receive a value: the runtime answers 303 back to + // the referring page with the outcome riding a one-shot flash cookie, and + // the render that follows decodes it (src/setup.tsx here — the + // integration's half). Since @solidjs/web 2.0.0-rc.7 that cookie is + // AES-GCM encrypted under a key derived from the deployment secret, and + // WITHOUT a secret the flash is withheld entirely (the redirect goes out + // plain, dev warns once). The plugin provides the secret with zero + // configuration — `globalThis.__SOLID_SECRET__ ??=` leads the generated + // handler module — so the Set-Cookie below is the proof that it reached + // the runtime, and the marker is the proof the render decrypted it under + // the same key. Headers mimic a real form navigation: form content type, + // `Sec-Fetch-Mode: navigate` (the convention's gate — hence rawRequest, + // fetch cannot send it), same-origin proof for the CSRF check, and the + // referrer the redirect returns to. + if (functionId) { + const back = origin + '/'; + const post = await rawRequest(`${origin}/_server/${encodeURIComponent(functionId)}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'text/html', + referer: back, + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'navigate', + }, + body: new URLSearchParams({ name: 'nojs-flash' }).toString(), + }); + record( + mode, + 'flash', + 'no-JS form POST to a server function redirects back to the referrer (303)', + post.status === 303 && post.headers.location === back, + `status ${post.status}, location ${JSON.stringify(post.headers.location)}`, + ); + const postCookies = post.setCookies; + const flashCookie = postCookies.find((cookie) => cookie.startsWith('flash=')) ?? null; + record( + mode, + 'flash', + 'redirect carries the encrypted flash cookie (deployment secret reached the runtime)', + !!flashCookie && /^flash=1\./.test(flashCookie), + `set-cookie: ${JSON.stringify(postCookies)}`, + ); + record( + mode, + 'flash', + 'flash cookie is one-shot and Lax (Max-Age=60, SameSite=Lax, HttpOnly)', + !!flashCookie && + /;\s*max-age=60\b/i.test(flashCookie) && + /;\s*samesite=lax\b/i.test(flashCookie) && + /;\s*httponly\b/i.test(flashCookie), + `set-cookie: ${JSON.stringify(flashCookie)}`, + ); + + // The render that follows the redirect: the page reads the cookie back, + // decrypts it, and surfaces the outcome (the flash's url is the unbound + // function base, its result the function's return, its input the + // submitted form) — then clears the cookie so a reload reads "no flash". + const flashed = await fetch(back, { + headers: { accept: 'text/html', cookie: flashCookie ? flashCookie.split(';')[0] : '' }, + }); + const flashedHtml = await flashed.text(); + const flashMarker = /flash:([^:<]+):([^:<]+):([^:<]+) /^flash=;/.test(cookie) && /max-age=0\b/i.test(cookie)), + `set-cookie: ${JSON.stringify(flashedCookies)}`, + ); + const plain = await fetch(back, { headers: { accept: 'text/html' } }); + record( + mode, + 'flash', + 'a render without the cookie carries no flash', + !/flash:/.test(await plain.text()), + ); + } } async function runMiddlewareMode() {