Skip to content

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

Closed
everton-dgn wants to merge 1 commit into
solidjs:nextfrom
everton-dgn:feat/start-nonce
Closed

everton-dgn wants to merge 1 commit into
solidjs:nextfrom
everton-dgn:feat/start-nonce

Conversation

@everton-dgn

Copy link
Copy Markdown

Summary

Fixes #388.

  • New start.nonce option: the path of a server-only module default-exporting (event) => CSPNonce | undefined | Promise<…>. The handler resolves it per request after the middleware chain, next to resolveRenderMode. A middleware can therefore generate the nonce, set the Content-Security-Policy header and hand the value over through event.locals.

  • The resolved nonce now reaches the generated entry's renderToStream: the _$HY bootstrap, the streamed data and swap scripts and the modulepreload links carry it. It also reaches:

    • the client-entry tag and the post-flush redirect fallback, through scriptNonce;
    • 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.

    A strict script-src 'nonce-…' 'strict-dynamic' policy works with generated entries, in dev and production, including hosts that dispatch through the default Fetchable (Nitro).

  • handleRequest(request, { nonce }) keeps precedence over the module and now reaches the render too. Authored entries receive the value as context.nonce.

  • The object form { script, style } no longer throws TypeError: value.replace is not a function in the client-entry transform. An invalid nonce is rejected with an error naming its source. That covers a primitive other than a string, an array, or an object with keys other than script / style.

Cause

The generated entry rendered with a fixed { manifest } and ignored context (src/ssr/index.ts at e4cdee4, L932 and L1394). options.nonce only reached createSSRResponse and the entry tag (L1279, L1428). Nothing inside the app could supply the nonce:

  • the default Fetchable drops host arguments by design (L1461);
  • middleware only sees (request, next);
  • @solidjs/web reads the nonce only from the render options.

The only way out was hand-written entry-server / entry-client files, which also drop the generated error boundary.

Change

  • src/ssr/index.ts:
    • StartOptions.nonce, with JSDoc in the renderMode style.
    • resolveNonceModule validates at config time: path only, and it must exist. It is server mode only, like setup and renderMode.
    • Generated assertNonce / resolveNonce apply the precedence options.nonce, then the module.
    • entry.render(request, { clientEntry, nonce, ...options.context }).
    • Generated entries pass nonce: context && context.nonce to renderToStream, including the start.setup path and the client-mode shell.
    • The transform and createSSRResponse take scriptNonce(nonce).
    • In dev, the head becomes devHead(nonceAttr, styleAttr) plus devStyles(...).
  • virtual-solid-manifest.d.ts: nonce on the handleRequest options, typed as CSPNonce.
  • README section and a minor changeset.
  • examples/start-ssr: src/nonce.ts, an SSR_NONCE knob, src/middleware.ts storing x-csp-nonce on locals, and a new nonce mode in test/run.mjs.

Verification

pnpm run build
cd examples/start-ssr && node test/run.mjs [mode]
cd examples/start-client && node test/run.mjs
Suite / mode origin/next this branch
start-ssr nonce 1/10 (the object-form TypeError aborts the override block) 18/18
start-ssr (all modes) not measured 663/663
start-client (all modes) not measured 65/65
start-ssr render-mode (baseline) 120/120 included above

The nonce mode run against origin/next's src/ssr/index.ts fails the new assertions: scripts and modulepreload links without the nonce, the TypeError for the object form, and no module import.

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: 100fb61

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

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