Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inject-deployment-secret.md
Original file line number Diff line number Diff line change
@@ -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__ ??= "<random-per-build>"`, 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.
31 changes: 31 additions & 0 deletions examples/start-ssr/src/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, unknown>).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 () => (
<>
<p id="setup-marker">{`setup:${pathname}:${user}:${seq}`}</p>
{flashMarker ? <p id="flash-marker">{flashMarker}</p> : null}
<App />
</>
);
Expand Down
120 changes: 120 additions & 0 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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:([^:<]+):([^:<]+):([^:<]+)</.exec(flashedHtml);
record(
mode,
'flash',
'following render decrypts the flash and surfaces the outcome (url, result, input)',
!!flashMarker &&
flashMarker[1] === `/_server/${functionId}` &&
flashMarker[2] === 'mw-user' &&
flashMarker[3] === 'nojs-flash',
flashMarker ? `marker ${JSON.stringify(flashMarker[0])}` : 'no flash marker in html',
);
const flashedCookies = flashed.headers.getSetCookie ? flashed.headers.getSetCookie() : [];
record(
mode,
'flash',
'render clears the flash cookie once read',
flashedCookies.some((cookie) => /^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() {
Expand Down
26 changes: 26 additions & 0 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<CompileOptions, 'directive' | 'definitions'> = {
directive,
definitions: {
Expand Down Expand Up @@ -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
Expand Down
Loading