From 100fb61ad11bd5f79ab4ab79c6e40fa706ee85bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Wed, 30 Sep 2026 14:13:18 -0300 Subject: [PATCH 01/13] feat: resolve the CSP nonce per request with start.nonce Generated entries rendered with a fixed { manifest }, so the hydration bootstrap, the streamed data and swap scripts and the modulepreload links never carried a nonce, and nothing inside the app could supply one. start.nonce names a module resolved after the middleware chain; the handler passes its result (or handleRequest's nonce, which wins) to the generated renderToStream, the client-entry tag, the post-flush redirect fallback and, in dev, the injected head tags. Authored entries receive it as context.nonce. The { script, style } form no longer throws in the client-entry transform, and invalid values are rejected. --- .changeset/start-csp-nonce.md | 7 + README.md | 52 ++++- examples/start-ssr/src/middleware.ts | 4 + examples/start-ssr/src/nonce.ts | 13 ++ examples/start-ssr/test/run.mjs | 284 ++++++++++++++++++++++++++- examples/start-ssr/vite.config.ts | 12 ++ src/ssr/index.ts | 167 ++++++++++++++-- virtual-solid-manifest.d.ts | 11 ++ 8 files changed, 531 insertions(+), 19 deletions(-) create mode 100644 .changeset/start-csp-nonce.md create mode 100644 examples/start-ssr/src/nonce.ts diff --git a/.changeset/start-csp-nonce.md b/.changeset/start-csp-nonce.md new file mode 100644 index 00000000..3e2a6706 --- /dev/null +++ b/.changeset/start-csp-nonce.md @@ -0,0 +1,7 @@ +--- +'@solidjs/vite-plugin': minor +--- + +`start.nonce`: a server-only module default-exporting `(event) => CSPNonce | undefined | Promise<...>` that resolves the request's CSP nonce inside the request scope after the middleware chain, so a middleware can generate the nonce, set the `Content-Security-Policy` header and hand the value over through `event.locals`. The handler passes the resolved nonce to the generated entry's `renderToStream` — the hydration bootstrap, the streamed data and swap scripts and the `modulepreload` links now carry it — as well as to the injected client-entry tag, the post-flush redirect fallback and, in dev, the head tags the handler injects (the style patch and Vite client scripts, the collected styles, and a `csp-nonce` meta for the styles the Vite client injects). Until now generated entries rendered without a nonce, so a strict `script-src 'nonce-…'` policy required hand-written `entry-server` / `entry-client` files (and gave up the generated error boundary). `handleRequest(request, { nonce })` keeps working as the per-call override and now reaches the render too; authored entries receive the value as `context.nonce`. + +Also fixes the object form of the nonce: `handleRequest(request, { nonce: { script, style } })` threw `TypeError: value.replace is not a function` while building the client-entry tag. The tag and the redirect fallback now take the script destination through `@solidjs/web`'s `scriptNonce`, and an invalid nonce from either source (including an array or an object with keys other than `script` / `style`) is rejected with an error naming it. diff --git a/README.md b/README.md index 0a8ff20d..cc01a82d 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ same server functions. The object form carries the options (`start: true` is pure sugar for `start: {}` — both mean the identical start mode with defaults, and `false`/absent means off): `app`, `document`, `entryServer`, `entryClient`, -`middleware`, `setup`, `renderMode`, `env`, `devtools`, `errorBoundary`, +`middleware`, `setup`, `renderMode`, `nonce`, `env`, `devtools`, `errorBoundary`, `css`, `external`, `node`, all documented below. Install `@solidjs/start-devtools` as a development dependency to add the @@ -583,6 +583,56 @@ response head when the awaited render completes (`@solidjs/web` 2.0.0-rc.7+), just as streaming freezes it at shell flush. Server mode only — in client mode the served shell has no boundaries to settle, so the option is a documented no-op there. +**`nonce`** — the per-request CSP nonce, for a `Content-Security-Policy` +with `script-src 'nonce-…'` and no `'unsafe-inline'`. A module path +(relative to the Vite root, following the `middleware`/`setup`/`renderMode` +convention) default-exporting `(event) => CSPNonce | undefined | +Promise<...>`, where `CSPNonce` is `@solidjs/web`'s `string | { script, +style }` (each a string, or `false` to leave that destination un-nonced). It runs inside the request scope after the middleware chain, so +the middleware that generates the nonce and sets the header can hand it over +through `event.locals`: + +```ts +// vite.config.ts +solid({ + start: { middleware: './src/middleware.ts', nonce: './src/nonce.ts' }, + ssr: true, +}); + +// src/middleware.ts +import { getRequestEvent } from '@solidjs/web'; + +export default async function csp(request: Request, next: (request?: Request) => Promise) { + const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))); + getRequestEvent()!.locals.nonce = nonce; + const response = await next(); + response.headers.set('Content-Security-Policy', `script-src 'nonce-${nonce}' 'strict-dynamic'`); + return response; +} + +// src/nonce.ts +import type { RequestEvent } from '@solidjs/web'; + +export default function nonce(event: RequestEvent) { + return event.locals.nonce as string | undefined; +} +``` + +The handler passes the resolved nonce to the generated entry's +`renderToStream`, so the hydration bootstrap, the streamed data and swap +scripts and the `modulepreload` links all carry it, and to the injected +client-entry tag and the post-flush redirect fallback — in dev as well, where +the head tags the handler injects carry it too: the style patch and Vite +client scripts (with `'strict-dynamic'`, the modules they load are trusted in +turn), the collected styles, and a `csp-nonce` meta the Vite client reads for +the styles it injects. Inline scripts your +own `Document` renders still need it spelled out +(`nonce={getRequestEvent()?.locals.nonce}`). Authored entries receive the +value as `context.nonce` in `render()`. Hosts driving the handler directly +can pass `handleRequest(request, { nonce })`, which wins over the module. +Server mode only — the client-mode shell is prerendered once at build time, +so there is no request to take a nonce from. + **`env`** — first-party typed environment variables. A schema file at the project root — `env.ts` (or `env.js`), probed automatically; point elsewhere with `start: { env: './path' }`, disable with `env: false` — diff --git a/examples/start-ssr/src/middleware.ts b/examples/start-ssr/src/middleware.ts index 4ae1dd74..aa429b99 100644 --- a/examples/start-ssr/src/middleware.ts +++ b/examples/start-ssr/src/middleware.ts @@ -120,6 +120,10 @@ async function first(request: Request, next: Next): Promise { const event = getRequestEvent()!; event.locals.order = ['first']; event.locals.user = 'mw-user'; + // `start.nonce` evidence (nonce mode): the nonce module reads what the + // chain stored, proving it runs after the middleware. + const cspNonce = request.headers.get('x-csp-nonce'); + if (cspNonce) event.locals.nonce = cspNonce; if (new URL(request.url).pathname === '/blocked') { // Early return: this Response never goes through createSSRResponse, so // the stub write below only reaches the wire through the handler diff --git a/examples/start-ssr/src/nonce.ts b/examples/start-ssr/src/nonce.ts new file mode 100644 index 00000000..170887d4 --- /dev/null +++ b/examples/start-ssr/src/nonce.ts @@ -0,0 +1,13 @@ +// Per-request CSP nonce for the nonce e2e mode (SSR_NONCE=module wires it +// through `start.nonce` in vite.config.ts, next to SSR_MIDDLEWARE=1). +// Server-only: only the generated handler imports it. The recipe from the +// README: the middleware generates the nonce (here it takes the test's +// `x-csp-nonce` header instead, to stay deterministic) and stores it on +// `event.locals`; this module hands it to the handler, which runs it after +// the chain. +import type { RequestEvent } from '@solidjs/web'; + +export default function nonce(event: RequestEvent): string | undefined { + const value = event.locals.nonce; + return typeof value === 'string' ? value : undefined; +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index a06cf87d..4f58e1c7 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -141,7 +141,7 @@ // // Requires the plugin built (pnpm build at the repo root) and Google Chrome. // Usage: node test/run.mjs -// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|extra-input|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] +// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] // (default: all) import { spawn, execSync, execFileSync } from 'node:child_process'; @@ -5892,6 +5892,286 @@ async function runVitestMode() { ); } +// `start.nonce` (SSR_NONCE in vite.config.ts): the per-request CSP nonce. +// Asserted on direct handler dispatch in dev and against the built handler: +// - `handleRequest(request, { nonce })` reaches the render, not just the +// client-entry tag: every ` + - ``; - lines.push(``, `const DEV_HEAD = ${JSON.stringify(devHead)};`); + // Functions of the request's nonce attributes: under a nonce-based + // CSP the two scripts carry the script nonce (`'strict-dynamic'` then + // trusts the modules the Vite client loads), the collected dev styles + // carry the style nonce, and the `csp-nonce` meta hands the style + // nonce to the Vite client for the styles it injects on HMR. + lines.push( + ``, + `function devHead(nonceAttr, styleAttr) {`, + ` return (styleAttr ? '' : '') +`, + ` '' + ${JSON.stringify(devStylePatch)} + '' +`, + ` '