diff --git a/.changeset/handle-request-nonce-pair.md b/.changeset/handle-request-nonce-pair.md new file mode 100644 index 00000000..0f1d279a --- /dev/null +++ b/.changeset/handle-request-nonce-pair.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +`handleRequest(request, { nonce })` accepts the `{ script, style }` form of `@solidjs/web`'s `CSPNonce` and uses its `script` value for the scripts the handler writes: the injected client-entry tag and the post-flush redirect fallback. A pair used to throw `TypeError: value.replace is not a function`. The option is now declared in the `virtual:solid-ssr-handler` types. diff --git a/.changeset/start-csp-nonce.md b/.changeset/start-csp-nonce.md new file mode 100644 index 00000000..563a74f4 --- /dev/null +++ b/.changeset/start-csp-nonce.md @@ -0,0 +1,7 @@ +--- +'@solidjs/vite-plugin': minor +--- + +New `start.nonce` option: a server-only module default-exporting `(event) => CSPNonce | undefined | Promise<...>`, called 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 resolved nonce reaches the generated entry's `renderToStream` (the hydration bootstrap, the streamed data and swap scripts and the `modulepreload` links), 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). Generated entries used to render without a nonce, so a strict `script-src 'nonce-…'` policy needed hand-written `entry-server` / `entry-client` files. `handleRequest(request, { nonce })` stays as the per-call override and now reaches the render too; authored entries receive the value as `context.nonce`. + +An empty `handleRequest` nonce (`undefined`, `null` or `''`) leaves the nonce to the module, and a `nonce` the host passes in `options.context` still reaches authored entries when no nonce resolves. An invalid nonce from either source (a primitive other than a string, an array, or an object other than `{ script, style }` with each a non-empty string or `false`) is rejected with an error naming its source. diff --git a/README.md b/README.md index 0a8ff20d..b44fc4c2 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,62 @@ 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. The injected client-entry +tag and the post-flush redirect fallback get it too, in dev as well, where the +head tags the handler injects also carry it: 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()` (a `nonce` passed in +`handleRequest`'s `context` is left alone when none resolves). Hosts driving +the handler directly can pass `handleRequest(request, { nonce })`, which wins +over the module unless it's empty (`undefined`, `null` or `''`). Server mode +only: the client-mode shell is prerendered once at build time, so there is no +request to take a nonce from (the per-call option still reaches the shell the +dev server renders). + **`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-client/test/run.mjs b/examples/start-client/test/run.mjs index 33b3fc3c..709ab67a 100644 --- a/examples/start-client/test/run.mjs +++ b/examples/start-client/test/run.mjs @@ -35,6 +35,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; import os from 'node:os'; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'vite'; const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const CHROME = @@ -325,6 +326,39 @@ async function devMode() { ); record('dev', 'entry', 'toolbar wraps the generated app', entry.includes('DevToolbar')); + // `start.nonce` is server-mode only (the built shell is prerendered, with + // no request to take a nonce from), but the dev server renders the shell + // per request, so a host's `handleRequest(request, { nonce })` reaches + // it: the injected client entry, the style patch and the Vite client. + { + const nodeEnv = process.env.NODE_ENV; + const probe = await createServer({ root: exampleDir, server: { middlewareMode: true } }); + try { + const handler = await probe.environments.ssr.runner.import('virtual:solid-ssr-handler'); + const shell = await ( + await handler.handleRequest( + new Request('http://localhost/', { headers: { accept: 'text/html' } }), + { nonce: 'client-nonce' }, + ) + ).text(); + const scripts = shell.match(/]*>/g) || []; + record( + 'dev', + 'shell', + 'handleRequest({ nonce }) reaches every script of the dev shell', + scripts.length >= 3 && + scripts.every((tag) => tag.includes(' nonce="client-nonce"')) && + !shell.includes('_$HY'), + scripts.join(' '), + ); + } finally { + await probe.close(); + // createServer sets NODE_ENV; the builds later in this run inherit it. + if (nodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = nodeEnv; + } + } + await runBrowserChecks('dev', origin); try { diff --git a/examples/start-ssr/src/middleware.ts b/examples/start-ssr/src/middleware.ts index 4ae1dd74..02642244 100644 --- a/examples/start-ssr/src/middleware.ts +++ b/examples/start-ssr/src/middleware.ts @@ -120,6 +120,13 @@ 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. `x-csp-nonce-json` + // stores any shape (a `{ script, style }` pair, an invalid value). + const cspNonce = request.headers.get('x-csp-nonce'); + if (cspNonce) event.locals.nonce = cspNonce; + const cspNonceJson = request.headers.get('x-csp-nonce-json'); + if (cspNonceJson) event.locals.nonce = JSON.parse(cspNonceJson); 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..87eb2940 --- /dev/null +++ b/examples/start-ssr/src/nonce.ts @@ -0,0 +1,16 @@ +// 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` / `x-csp-nonce-json` headers instead, to stay +// deterministic) and stores it on `event.locals`; this module hands it to +// the handler, which runs it after the chain and validates the result. The +// `x-csp-nonce-async` header is the test's switch for the async form. +import type { CSPNonce, RequestEvent } from '@solidjs/web'; + +export default function nonce( + event: RequestEvent, +): CSPNonce | undefined | Promise { + const value = event.locals.nonce as CSPNonce | undefined; + return event.request.headers.has('x-csp-nonce-async') ? Promise.resolve(value) : value; +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index a06cf87d..852345b3 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'; @@ -388,6 +388,17 @@ function record(mode, phase, name, ok, detail = '') { console.log(` [${mode}/${phase}] ${status} ${name}${detail && !ok ? ` — ${detail}` : ''}`); } +// Reads a handler call's whole body; a rejection becomes the assertion's +// detail instead of aborting the whole run. +async function settledText(call) { + try { + const response = await call(); + return { text: await response.text(), error: '' }; + } catch (error) { + return { text: '', error: String(error) }; + } +} + // Pull the function id for `name` out of the client-transformed module so // the endpoint can be hit directly. function extractFunctionId(transformedCode, name) { @@ -1446,6 +1457,51 @@ async function runProdMode() { 'client entry carries the escaped CSP nonce', nonceHtml.includes('` + - ``; - 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)} + '' +`, + ` '