feat: resolve the CSP nonce per request with start.nonce - #389
Open
everton-dgn wants to merge 14 commits into
Open
everton-dgn wants to merge 14 commits into
everton-dgn wants to merge 14 commits into
Conversation
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 detectedLatest commit: ae2b5a8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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.
Author
|
Reopening. Since the first round, the object-form fix is split out to #392, and this branch now:
The description has the details and the results. |
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #388.
start.noncenames a server-only module that resolves the request's CSP nonce, following therenderModeconvention. It default-exports(event) => CSPNonce | undefined | Promise<…>, and the handler calls it at the end of the middleware chain, right before the render, next toresolveRenderMode. A middleware can generate the nonce, set theContent-Security-Policyheader and leave the value onevent.localsfor the module to return.The resolved nonce (
handleRequest'snoncekeeps precedence) now reaches:renderToStream, so the hydration bootstrap, the streamed data and swap scripts and themodulepreloadlinks carry it;csp-noncemeta for the styles the Vite client adds;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 ignorecontext, and the default Fetchable callshandleRequest(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 strictscript-src 'nonce-…'policy needs a hand-writtenentry-server/entry-clientpair, 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
CSPNonceobject-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 mergenexthere, and this diff will only carry the feature.options.context, so a host'scontext.noncecan't hand the render a different value from the one on the client-entry tag and the redirect fallback. When nothing resolves, the host'scontext.noncereaches the entry as it does onnext.handleRequestnonce (undefined,nullor'', 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.{ script, style }pair needs both keys, as theCSPNoncetype has it, each a non-empty string orfalse:{},{ script: 'x' }and{ script: '', style: 'x' }are rejected instead of leaving a destination without a nonce.Public API changes
start.nonceoption (module path).handleRequest'snoncenow reaches the render too, not only the client-entry tag and the redirect fallback. An empty value defers tostart.nonce, and the option is typedCSPNonce | null.context.nonce, in place of anoncepassed inoptions.context, which is left alone when nothing resolves.Design notes
event.locals.noncekey: it has the same shape asrenderMode(a module path, called per request after the chain, overridable per call), it doesn't reserve a key inlocals, 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, astart.nonce: truethat readsevent.locals.noncecould sit on top of this without changing the rest.csp-noncemeta is what Vite's client reads (meta[property=csp-nonce], through itsnonceproperty) for the<style>tags it injects. Vite writes the same meta whenhtml.cspNonceis set, but that option is a placeholder applied while Vite transforms anindex.html. Here the handler writes the dev head per request, so it writes the meta itself, only when there's a style nonce.starttoapp. This branch follows the naming onnext. If refactor: rename Start mode to app mode #327 lands first, the rename here is the option key, thestart.noncestrings in the errors and docs, and the test labels.examples/start-ssr/test/run.mjsandexamples/start-client/test/run.mjs) and 163 are insrc/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.mjsgains anoncemode (36 assertions), with the module inexamples/start-ssr/src/nonce.tsreading what the example middleware stores:modulepreloadcarries 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 withoutstyle, a number asstyle, an emptyscript); a resolved nonce wins overoptions.context.nonce;event.locals; the option wins over it, whilenulland''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 ascontext.nonce, and a host's owncontext.noncewhen 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 throughhandleRequestand through the default Fetchable;examples/start-client/test/run.mjsgains one check: in dev,handleRequest(request, { nonce })reaches every script of the client-mode shell.The nonce mode fails against
next'ssrc/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 whatnextalready does, sending no nonce and letting a host's owncontext.noncereach 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 testinexamples/start-ssrpasses (run.mjs685/685,http-bridge10/10,components-warning11/11,webworker-warning12/12,dedupe8/8), and so doesexamples/start-client(66/66).In a real app
SolidJS 2 on Nitro (vercel preset) with
script-src 'nonce-…' 'strict-dynamic'. On3.0.0-next.47it needs an authoredentry-server/entry-clientpair, a custom server entry that passes the nonce tohandleRequestfor the redirect fallback, a type overload declaringhandleRequest'snonceoption 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: falsebecause it has its own root boundary), and the module is:In Chromium, through Playwright:
modulepreload, a new nonce per request, hydration without CSP violations and client navigation;vite devwith 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 on3.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.