Skip to content
Merged
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`. A value outside `CSPNonce` (anything but a string or a `{ script, style }` object with both keys, each a non-empty string or `false`) now rejects the call up front with an error naming the option, before the middleware chain runs; an empty value (`undefined`, `null` or `''`) still means no nonce. The option is now declared in the `virtual:solid-ssr-handler` types.
93 changes: 93 additions & 0 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,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) {
Expand Down Expand Up @@ -1464,6 +1475,88 @@ async function runProdMode() {
'client entry carries the escaped CSP nonce',
nonceHtml.includes('<script type="module" nonce="test&quot;&lt;&amp;" src="'),
);
// @solidjs/web's CSPNonce also takes a `{ script, style }` pair. The tags
// the handler writes are scripts, so they take the `script` value.
const pairNonce = { script: 'pair-script', style: 'pair-style' };
const pairPage = await settledText(() =>
builtHandler.handleRequest(new Request(origin + '/'), { nonce: pairNonce }),
);
record(
mode,
'build',
'client entry takes the script nonce of a { script, style } pair',
pairPage.text.includes('<script type="module" nonce="pair-script" src="') &&
!/<script\b[^>]*nonce="pair-style"/.test(pairPage.text),
pairPage.error,
);
const pairRedirect = await settledText(() =>
builtHandler.handleRequest(new Request(origin + '/redirect-post'), { nonce: pairNonce }),
);
record(
mode,
'build',
'post-flush redirect fallback takes the script nonce of a { script, style } pair',
pairRedirect.text.includes('<script nonce="pair-script">window.location='),
pairRedirect.error,
);
const styleOnlyNonce = { script: false, style: 'pair-style' };
const styleOnlyPage = await settledText(() =>
builtHandler.handleRequest(new Request(origin + '/'), { nonce: styleOnlyNonce }),
);
record(
mode,
'build',
'a pair without a script nonce leaves the client entry un-nonced',
styleOnlyPage.text.includes('<script type="module" src="'),
styleOnlyPage.error,
);
const styleOnlyRedirect = await settledText(() =>
builtHandler.handleRequest(new Request(origin + '/redirect-post'), { nonce: styleOnlyNonce }),
);
record(
mode,
'build',
'a pair without a script nonce leaves the redirect fallback un-nonced',
styleOnlyRedirect.text.includes('<script>window.location='),
styleOnlyRedirect.error,
);
// An empty nonce means none, as in the runtime.
for (const empty of [null, '']) {
const emptyPage = await settledText(() =>
builtHandler.handleRequest(new Request(origin + '/'), { nonce: empty }),
);
record(
mode,
'build',
`an empty nonce (${JSON.stringify(empty)}) leaves the client entry un-nonced`,
emptyPage.text.includes('<script type="module" src="'),
emptyPage.error,
);
}
// Anything outside CSPNonce rejects the call itself, before the chain
// runs: the host's own error, not a contained request failure.
for (const [label, invalid] of [
['a number', 42],
['an array', ['x']],
['a pair without style', { script: 'x' }],
['a misspelled key', { scirpt: 'x', style: 'y' }],
['an empty script', { script: '', style: 'y' }],
['a number as style', { script: 'x', style: 1 }],
]) {
const outcome = await builtHandler
.handleRequest(new Request(origin + '/'), { nonce: invalid })
.then(
(response) => `resolved ${response.status}`,
(error) => String(error),
);
record(
mode,
'build',
`an invalid nonce (${label}) rejects handleRequest`,
outcome.includes('handleRequest options.nonce must be'),
outcome,
);
}
record(
mode,
'build',
Expand Down
36 changes: 33 additions & 3 deletions src/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,7 @@ export function startServe(
const composeServerFunctions = internal.serverFunctions;

const lines = [
`import { createRequestEvent, createSSRResponse, commitEventResponse${middlewarePath ? ', composeMiddleware' : ''}, isResponseEnvelope } from '@solidjs/web';`,
`import { createRequestEvent, createSSRResponse, commitEventResponse, scriptNonce${middlewarePath ? ', composeMiddleware' : ''}, isResponseEnvelope } from '@solidjs/web';`,
...(isBuild ? [`import { reportServerError } from 'solid-js/internal';`] : []),
`import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`,
`import * as entry from ${JSON.stringify(entryServerSpec())};`,
Expand Down Expand Up @@ -1263,6 +1263,31 @@ export function startServe(
);
}

// CSP nonce (`handleRequest(request, { nonce })`): @solidjs/web's
// `CSPNonce`, a string or a `{ script, style }` pair with both keys,
// each a non-empty string or `false`. An empty value (undefined, null or
// '') means none, as in the runtime. Anything else is rejected: projected
// as is, a typo'd key or a number would leave the tags without a nonce
// without a word.
lines.push(
``,
`function assertNonce(nonce, source) {`,
` if (nonce == null || typeof nonce === 'string') return nonce || undefined;`,
` const prototype = typeof nonce === 'object' ? Object.getPrototypeOf(nonce) : undefined;`,
` const destination = (value) => value === false || (typeof value === 'string' && value !== '');`,
` if (`,
` (prototype === Object.prototype || prototype === null) &&`,
` Object.keys(nonce).every((key) => key === 'script' || key === 'style') &&`,
` destination(nonce.script) &&`,
` destination(nonce.style)`,
` ) {`,
` return nonce;`,
` }`,
` const got = Array.isArray(nonce) ? 'an array' : prototype ? 'an object with keys ' + JSON.stringify(Object.keys(nonce)) : typeof nonce;`,
` throw new Error('[@solidjs/vite-plugin] ' + source + ' must be a string, a { script, style } object (each a non-empty string or false), or undefined; got ' + got);`,
`}`,
);

// No `_$SC` bootstrap injection: the runtime's serialized
// server-component references self-bootstrap the registry (each
// hydration script's first reference carries it as an idempotent
Expand Down Expand Up @@ -1424,10 +1449,14 @@ export function startServe(
// The runtime's response-head lifecycle: commit at shell flush,
// pre-flush Location as a real redirect, post-flush Location as the
// script fallback; the transform injects the doctype/head pieces.
// Both write a single script, so a `{ script, style }` nonce
// contributes its script value, as with @solidjs/web's other
// single-script surfaces.
` const nonce = scriptNonce(options.nonce);`,
` return createSSRResponse(result, event, {`,
` responseInit: options.responseInit,`,
` nonce: options.nonce,`,
` transformChunk: createHtmlChunkTransform(clientEntry, options.devHead, options.nonce),`,
` nonce,`,
` transformChunk: createHtmlChunkTransform(clientEntry, options.devHead, nonce),`,
` });`,
`}`,
``,
Expand Down Expand Up @@ -1496,6 +1525,7 @@ export function startServe(
// rejects up front, before the chain runs, instead of being contained
// as a request failure.
` if (options.renderMode !== undefined) assertRenderMode(options.renderMode, 'handleRequest options.renderMode');`,
` assertNonce(options.nonce, 'handleRequest options.nonce');`,
// `options.event` is the public wrapper->event extension seam: extra
// fields (conventionally `nativeEvent`, the platform's raw request
// object) spread over the event's defaults at creation, so hosts and
Expand Down
10 changes: 10 additions & 0 deletions virtual-solid-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ declare module "virtual:solid-server-function-handler" {
// manifest, and — when `serverFunctions` is enabled — serves the
// server-function endpoint ahead of SSR.
declare module "virtual:solid-ssr-handler" {
import type { CSPNonce } from "@solidjs/web";

export function handleRequest(
request: Request,
options?: {
Expand All @@ -49,6 +51,14 @@ declare module "virtual:solid-ssr-handler" {
context?: Record<string, unknown>;
/** Status/headers for the HTML response. */
responseInit?: ResponseInit;
/**
* CSP nonce for the scripts the handler writes into the document: the
* injected client-entry tag and the fallback of a redirect decided
* after the shell flushed. A `{ script, style }` pair contributes its
* `script` value. Any value outside `CSPNonce` rejects the call before
* the middleware chain runs.
*/
nonce?: CSPNonce;
/**
* Per-call render mode, overriding `start.renderMode` (static value or
* per-request module alike). `'stream'` flushes the document shell
Expand Down
Loading