Skip to content
Open
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/handle-request-nonce-pair.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/start-csp-nonce.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Response>,
) {
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` —
Expand Down
34 changes: 34 additions & 0 deletions examples/start-client/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(/<script\b[^>]*>/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 {
Expand Down
7 changes: 7 additions & 0 deletions examples/start-ssr/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ async function first(request: Request, next: Next): Promise<Response> {
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
Expand Down
16 changes: 16 additions & 0 deletions examples/start-ssr/src/nonce.ts
Original file line number Diff line number Diff line change
@@ -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<CSPNonce | undefined> {
const value = event.locals.nonce as CSPNonce | undefined;
return event.request.headers.has('x-csp-nonce-async') ? Promise.resolve(value) : value;
}
Loading