Skip to content

feat: resolve the CSP nonce per request with start.nonce - #389

Open
everton-dgn wants to merge 14 commits into
solidjs:nextfrom
everton-dgn:feat/start-nonce
Open

everton-dgn wants to merge 14 commits into
solidjs:nextfrom
everton-dgn:feat/start-nonce

Conversation

@everton-dgn

@everton-dgn everton-dgn commented Sep 30, 2026 •

Copy link
Copy Markdown

Summary

Fixes #388.

start.nonce names a server-only module that resolves the request's CSP nonce, following the renderMode convention. It default-exports (event) => CSPNonce | undefined | Promise<…>, and the handler calls it at the end of the middleware chain, right before the render, next to resolveRenderMode. A middleware can generate the nonce, set the Content-Security-Policy header and leave the value on event.locals for the module to return.

The resolved nonce (handleRequest's nonce keeps precedence) now reaches:

  • the generated entry's renderToStream, so the hydration bootstrap, the streamed data and swap scripts and the modulepreload links carry it;
  • the injected client-entry tag and the post-flush redirect fallback;
  • 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 adds;
  • authored entries, as context.nonce.

An invalid nonce from either source is rejected with an error that names the source.

Why

Generated entries render with a fixed { manifest } and ignore context, and the default Fetchable calls handleRequest(request) without options, which is how Nitro dispatches. Middleware only sees (request, next), so nothing inside the app can hand a nonce to the render. Today a strict script-src 'nonce-…' policy needs a hand-written entry-server / entry-client pair, plus a custom server entry for the redirect fallback, and the pair gives up the generated error boundary. In dev there's no way around it: the style patch and the Vite client script the handler injects never carry a nonce.

Changes since the first round

  • The CSPNonce object-form fix moved to fix: accept the { script, style } CSP nonce in handleRequest #392 so it can land on its own. Once it's merged I'll merge next here, and this diff will only carry the feature.
  • A resolved nonce goes over options.context, so a host's context.nonce can't hand the render a different value from the one on the client-entry tag and the redirect fallback. When nothing resolves, the host's context.nonce reaches the entry as it does on next.
  • An empty handleRequest nonce (undefined, null or '', all "no nonce" to the runtime) leaves the decision to the module instead of switching it off. { script: false, style: false } still sends a request without one.
  • A { script, style } pair needs both keys, as the CSPNonce type has it, each a non-empty string or false: {}, { script: 'x' } and { script: '', style: 'x' } are rejected instead of leaving a destination without a nonce.
  • More coverage, listed under Verification.

Public API changes

  • New start.nonce option (module path).
  • handleRequest's nonce now reaches the render too, not only the client-entry tag and the redirect fallback. An empty value defers to start.nonce, and the option is typed CSPNonce | null.
  • Authored entries receive the resolved nonce as context.nonce, in place of a nonce passed in options.context, which is left alone when nothing resolves.

Design notes

  • Why a module and not a fixed event.locals.nonce key: it has the same shape as renderMode (a module path, called per request after the chain, overridable per call), it doesn't reserve a key in locals, and the value can come from somewhere else (a header set by a proxy, the platform context), be a { script, style } pair or be async. If you'd rather have the convention, a start.nonce: true that reads event.locals.nonce could sit on top of this without changing the rest.
  • The csp-nonce meta is what Vite's client reads (meta[property=csp-nonce], through its nonce property) for the <style> tags it injects. Vite writes the same meta when html.cspNonce is set, but that option is a placeholder applied while Vite transforms an index.html. Here the handler writes the dev head per request, so it writes the meta itself, only when there's a style nonce.
  • refactor: rename Start mode to app mode #327 renames start to app. This branch follows the naming on next. If refactor: rename Start mode to app mode #327 lands first, the rename here is the option key, the start.nonce strings in the errors and docs, and the test labels.
  • Size: of the 930 added lines, 648 are tests (examples/start-ssr/test/run.mjs and examples/start-client/test/run.mjs) and 163 are in src/ssr/index.ts, which includes the fix: accept the { script, style } CSP nonce in handleRequest #392 part until it's merged.

Verification

examples/start-ssr/test/run.mjs gains a nonce mode (36 assertions), with the module in examples/start-ssr/src/nonce.ts reading what the example middleware stores:

  • the per-call option: every script and modulepreload carries it, escaped, and so does the redirect fallback; the object form; the dev head (styles and meta), including a pair with distinct values; invalid values (a number, an array, a misspelled key, {}, a pair without style, a number as style, an empty script); a resolved nonce wins over options.context.nonce;
  • the module: it reads what the middleware stored on event.locals; the option wins over it, while null and '' defer to it; { script: false, style: false } turns it off, from either source; an async result; a pair; an invalid result names the module; a module without a default-exported function is rejected; the handler imports it only when configured;
  • start.setup; authored entries (the resolved nonce arrives as context.nonce, and a host's own context.nonce when nothing resolves); the dev server end to end, on a page with a lazy component and its stylesheet (every <style> has the style nonce, every <script> the script nonce); the built handler through handleRequest and through the default Fetchable;
  • a missing module path is rejected at config time.

examples/start-client/test/run.mjs gains one check: in dev, handleRequest(request, { nonce }) reaches every script of the client-mode shell.

The nonce mode fails against next's src/ssr/index.ts (4 of the 22 checks that get to run pass: the object form throws and aborts the rest of the override block, and the four that pass check what next already does, sending no nonce and letting a host's own context.nonce reach an authored entry). Against this branch before the changes above (744ab07) it's 29/36, and the seven failures are those changes.

On this branch, pnpm test in examples/start-ssr passes (run.mjs 685/685, http-bridge 10/10, components-warning 11/11, webworker-warning 12/12, dedupe 8/8), and so does examples/start-client (66/66).

In a real app

SolidJS 2 on Nitro (vercel preset) with script-src 'nonce-…' 'strict-dynamic'. On 3.0.0-next.47 it needs an authored entry-server / entry-client pair, a custom server entry that passes the nonce to handleRequest for the redirect fallback, a type overload declaring handleRequest's nonce option and a Vite plugin that swaps the SSR input, and it keeps the CSP off in dev. That setup passes the app's production suite in CI (run).

With this branch packed, those files are gone. The app sets start: { nonce: './src/nonce.ts', errorBoundary: false, … } (errorBoundary: false because it has its own root boundary), and the module is:

import type { RequestEvent } from '@solidjs/web';

export default function nonce(event: RequestEvent): string | undefined {
  return event.locals.nonce;
}

In Chromium, through Playwright:

  • the production suite: 20/20, including the nonce on every script and modulepreload, a new nonce per request, hydration without CSP violations and client navigation;
  • the full suite: 82 passed, one of them an expected failure that marks a runtime limit this PR doesn't touch (SSR streaming: fragment stylesheet links use inline onload/onerror handlers, which a nonce-based CSP blocks solid#3747);
  • vite dev with the CSP on: 11/11, covering the nonce on every script and style, hydration, the Vite client connecting, a CSS Module edit and a TSX edit applied as HMR updates with no reload, and no violation. The original app on 3.0.0-next.47, with its dev-only CSP switch removed so it sends the same policy, gets 2/11: the style patch and the Vite client tag the handler injects are blocked, and the page neither hydrates nor connects to HMR.

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-bot

changeset-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ae2b5a8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/vite-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

The handler built the render context as `{ clientEntry, nonce,
...options.context }`, so a host passing `context.nonce` gave the render
one nonce while the client-entry tag and the redirect fallback got
another. The resolved nonce now goes last. The nonce mode checks it with
conflicting values: 18/19 before, 19/19 after.
Declare `nonce` on the handleRequest options through
`import type { CSPNonce } from "@solidjs/web"` instead of an inline copy
of the type, next to `responseInit`, and tighten the README section and
the changeset.
The generated handler passed `options.nonce` unchanged to the
client-entry transform, whose `escapeAttribute` calls `.replace` on it,
and to `createSSRResponse`, which takes a string, so a `{ script, style }`
nonce threw `TypeError: value.replace is not a function`. Project it with
`scriptNonce` for both, and declare the option in the
`virtual:solid-ssr-handler` types.
The CSPNonce type has both destinations. `{}` and `{ script: 'x' }` passed
validation and left a destination without a nonce; they're now rejected
with the error that names the source.
The runtime reads undefined, null and '' as no nonce. `handleRequest(request,
{ nonce: null })` (or '') skipped the module and sent the page without one;
now the module decides. `{ script: false, style: false }` still sends a
request without a nonce.
A resolved nonce still goes over options.context, so the render, the
client-entry tag and the redirect fallback agree. Without one, the spread
wrote `nonce: undefined` over a nonce the host passed in options.context;
it now reaches the entry as on next.
The nonce mode now checks the module's async and pair results, an invalid
result and a missing default export, the empty and false/false overrides,
incomplete pairs, a pair with distinct values in the dev head, the escaped
redirect fallback, authored entries both ways, and the real dev server on a
page with a lazy component's CSS.
The runtime drops an empty string, so `{ script: '', style: 'x' }` sent the
scripts without a nonce, and `{ script: '', style: '' }` switched the module
off while a bare '' defers to it.
null already defers to start.nonce at runtime; the type now says so. The
README mentions that a host's own context.nonce is left alone when none
resolves, and the d.ts says it reaches generated entries too.
@everton-dgn everton-dgn reopened this Oct 3, 2026
@everton-dgn

Copy link
Copy Markdown
Author

Reopening. Since the first round, the object-form fix is split out to #392, and this branch now:

  • keeps a host's options.context.nonce when no nonce resolves (a resolved one still wins, so the render and the handler's tags agree);
  • lets an empty handleRequest nonce (undefined, null, '') defer to the module;
  • requires both keys in a { script, style } pair, each a non-empty string or false;
  • covers the module's failure modes, async and pair results, authored entries, the dev server and the client-mode dev shell.

The description has the details and the results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant