diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index 6283213d99..2bda28376c 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -868,7 +868,60 @@ createFromReadableStream(getRSCStream()).then( ); ``` +## Content Security Policy nonces + +A [Content Security Policy][csp] can use a per-response nonce to allow the inline scripts required for RSC hydration without allowing arbitrary inline scripts. The nonce is an HTML concern, so configure it in `entry.ssr.tsx`; it does not need to be passed to `matchRSCServerRequest` or included in the RSC payload. + +In RSC Framework Mode, first run `react-router reveal entry.ssr` to create a custom SSR entry. In RSC Data Mode, update your existing SSR entry. Generate a fresh nonce for each document response, then pass it to `routeRSCServerRequest`, the `RSCStaticRouter`, and your CSP response header: + +```tsx filename=app/entry.ssr.tsx +export async function generateHTML( + request: Request, + serverResponse: Response, +): Promise { + const nonce = crypto.randomUUID(); + + const response = await routeRSCServerRequest({ + request, + serverResponse, + createFromReadableStream, + nonce, + async renderHTML(getPayload, options) { + const payload = getPayload(); + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent( + "index", + ); + + return renderHTMLToReadableStream( + , + { + ...options, + bootstrapScriptContent, + formState: await payload.formState, + signal: request.signal, + }, + ); + }, + }); + + response.headers.set( + "Content-Security-Policy", + `script-src 'self' 'nonce-${nonce}'`, + ); + return response; +} +``` + +The `nonce` option on `routeRSCServerRequest` applies the nonce to the inline scripts that transfer the RSC payload into the HTML document. Spreading its `renderHTML` options into `renderHTMLToReadableStream` applies the same nonce to scripts generated by React. Passing it to `RSCStaticRouter` makes it the default for nonce-aware components such as `` and ``. + +The default RSC Framework entry does not generate a nonce. Only generate one when your application also sends a matching CSP header. For statically prerendered pages, prefer CSP hashes or external scripts instead of a per-response nonce. + [picking-a-mode]: ../start/modes +[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP [react-server-components-doc]: https://react.dev/reference/rsc/server-components [react-server-functions-doc]: https://react.dev/reference/rsc/server-functions [use-client-docs]: https://react.dev/reference/rsc/use-client diff --git a/docs/how-to/security.md b/docs/how-to/security.md index d3ec9491d1..6e9415789f 100644 --- a/docs/how-to/security.md +++ b/docs/how-to/security.md @@ -4,7 +4,7 @@ title: Security # Security -[MODES: framework] +[MODES: framework, data]

@@ -13,6 +13,8 @@ This is by no means a comprehensive guide, but React Router provides features to ## `Content-Security-Policy` +### Framework Mode without RSC + If you are implementing a [Content-Security-Policy (CSP)][csp] in your application, specifically one using the `unsafe-inline` directive, you will need to specify a [`nonce`][nonce] attribute on the inline `', + ); + }); + it("does not crash when the readable side is cancelled while a flush is pending", async () => { let rsc = createRSCStream({ keepOpen: true }); let transform = injectRSCPayload(rsc.stream); @@ -169,6 +191,67 @@ describe("injectRSCPayload", () => { }); describe("routeRSCServerRequest", () => { + it("passes a nonce to the HTML renderer and RSC payload scripts", async () => { + let renderNonce: string | undefined; + let response = await routeRSCServerRequest({ + request: new Request("https://remix.run/"), + serverResponse: new Response(createRSCStream().stream), + nonce: "test-nonce", + createFromReadableStream: async (body) => { + await readStream(body); + return { type: "render" } as never; + }, + async renderHTML(getPayload, options) { + await getPayload(); + renderNonce = options.nonce; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("hi")); + controller.close(); + }, + }); + }, + }); + + expect(renderNonce).toBe("test-nonce"); + await expect(readStream(response.body!)).resolves.toContain( + '`, ), ); } +function escapeAttribute(value: string) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + // Escape closing script tags and HTML comments in JS content. // https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements // Avoid replacing ; - nonce?: string; formState?: ReactFormState; }; diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 9551fff246..8dae5b56ad 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -46,12 +46,17 @@ export type SSRCreateFromReadableStreamFunction = ( * request, * serverResponse, * createFromReadableStream, - * async renderHTML(getPayload) { + * nonce, + * async renderHTML(getPayload, options) { * const payload = getPayload(); * * return await renderHTMLToReadableStream( - * , + * , * { + * ...options, * bootstrapScriptContent, * formState: await payload.formState, * } @@ -69,6 +74,8 @@ export type SSRCreateFromReadableStreamFunction = ( * @param opts.serverResponse A Response or partial response generated by the [RSC](https://react.dev/reference/rsc/server-components) handler containing a serialized {@link unstable_RSCPayload}. * @param opts.hydrate Whether to hydrate the server response with the RSC payload. * Defaults to `true`. + * @param opts.nonce An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) + * for inline scripts generated while rendering the HTML document. * @param opts.renderHTML A function that renders the {@link unstable_RSCPayload} to * HTML, usually using a {@link unstable_RSCStaticRouter | ``}. * @param opts.request The request to route. @@ -82,6 +89,7 @@ export async function routeRSCServerRequest({ createFromReadableStream, renderHTML, hydrate = true, + nonce, }: { request: Request; serverResponse: Response; @@ -89,11 +97,13 @@ export async function routeRSCServerRequest({ renderHTML: ( getPayload: () => DecodedPayload, options: { + nonce?: string; onError(error: unknown): string | undefined; onHeaders(headers: Headers): void; }, ) => ReadableStream | Promise>; hydrate?: boolean; + nonce?: string; }): Promise { const url = new URL(request.url); const isDataRequest = isReactServerRequest(url); @@ -213,6 +223,7 @@ export async function routeRSCServerRequest({ let statusText = serverResponse.statusText; let html = await renderHTML(getPayload, { + nonce, onError(error: unknown) { if ( typeof error === "object" && @@ -287,7 +298,7 @@ export async function routeRSCServerRequest({ } const body = html - .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(injectRSCPayload(serverResponseB.body, { nonce })) .pipeThrough(redirectTransform); return new Response(body, { status, @@ -356,6 +367,7 @@ export async function routeRSCServerRequest({ }) as unknown as DecodedPayload; }, { + nonce, onError(error: unknown) { if ( typeof error === "object" && @@ -432,7 +444,7 @@ export async function routeRSCServerRequest({ } const body = html - .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(injectRSCPayload(serverResponseB.body, { nonce })) .pipeThrough(retryRedirectTransform); return new Response(body, { status, @@ -462,6 +474,12 @@ export interface RSCStaticRouterProps { * through from {@link unstable_routeRSCServerRequest}'s `renderHTML`. */ getPayload: () => DecodedPayload; + /** + * An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) + * used as the default for nonce-aware components such as `` and + * ``. + */ + nonce?: string; } /** @@ -480,12 +498,17 @@ export interface RSCStaticRouterProps { * request, * serverResponse, * createFromReadableStream, - * async renderHTML(getPayload) { + * nonce, + * async renderHTML(getPayload, options) { * const payload = getPayload(); * * return await renderHTMLToReadableStream( - * , + * , * { + * ...options, * bootstrapScriptContent, * formState: await payload.formState, * } @@ -499,9 +522,10 @@ export interface RSCStaticRouterProps { * @mode data * @param props Props * @param {unstable_RSCStaticRouterProps.getPayload} props.getPayload n/a + * @param {unstable_RSCStaticRouterProps.nonce} props.nonce n/a * @returns A React component that renders the {@link unstable_RSCPayload} as HTML. */ -export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { +export function RSCStaticRouter({ getPayload, nonce }: RSCStaticRouterProps) { const decoded = getPayload(); const payload = React.use(decoded); @@ -615,6 +639,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { payload.routeDiscovery.manifestPath || defaultManifestPath, }, routeModules: createRSCRouteModules(payload), + nonce, }; return ( @@ -625,7 +650,6 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { context={context} router={router} hydrate={false} - nonce={payload.nonce} />