diff --git a/docs/api/hooks/useLinkClickHandler.md b/docs/api/hooks/useLinkClickHandler.md index a484f7682a..4b0e014bd0 100644 --- a/docs/api/hooks/useLinkClickHandler.md +++ b/docs/api/hooks/useLinkClickHandler.md @@ -89,7 +89,8 @@ Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/Vie ### options.defaultShouldRevalidate -Specify the default revalidation behavior for the navigation. Defaults to `true`. +Specify the default revalidation behavior for the navigation. When not specified, loaders revalidate +according to the router's standard revalidation behavior. ### options.mask diff --git a/docs/api/rsc/RSCStaticRouter.md b/docs/api/rsc/RSCStaticRouter.md index e402d1d507..65930906d6 100644 --- a/docs/api/rsc/RSCStaticRouter.md +++ b/docs/api/rsc/RSCStaticRouter.md @@ -45,12 +45,17 @@ routeRSCServerRequest({ request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + nonce, + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( - , + , { + ...options, bootstrapScriptContent, formState: await payload.formState, } @@ -62,7 +67,7 @@ routeRSCServerRequest({ ## Signature ```tsx -function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) +function RSCStaticRouter({ getPayload, nonce }: RSCStaticRouterProps) ``` ## Props @@ -72,3 +77,9 @@ function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) A function that starts decoding of the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react-router.unstable_RSCPayload.html). Usually passed through from [`unstable_routeRSCServerRequest`](../rsc/routeRSCServerRequest)'s `renderHTML`. +### nonce + +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 +``. + diff --git a/docs/api/rsc/matchRSCServerRequest.md b/docs/api/rsc/matchRSCServerRequest.md index f140225600..c2cdebc504 100644 --- a/docs/api/rsc/matchRSCServerRequest.md +++ b/docs/api/rsc/matchRSCServerRequest.md @@ -79,6 +79,7 @@ async function matchRSCServerRequest({ loadServerAction, decodeAction, decodeFormState, + clientVersion, onError, request, routes, @@ -92,6 +93,7 @@ async function matchRSCServerRequest({ decodeFormState?: DecodeFormStateFunction; requestContext?: RouterContextProvider; loadServerAction?: LoadServerActionFunction; + clientVersion?: string; onError?: (error: unknown) => void; request: Request; routes: RSCRouteConfigEntry[]; @@ -147,6 +149,10 @@ encoding the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react- Your `react-server-dom-xyz/server`'s `loadServerAction` function, used to load a server action by ID. +### opts.clientVersion + +A version derived from the client build output used to detect stale clients during lazy route discovery. + ### opts.onError An optional error handler that will be called with any errors that occur during the request processing. diff --git a/docs/api/rsc/routeRSCServerRequest.md b/docs/api/rsc/routeRSCServerRequest.md index 3f716940e6..5307f33d47 100644 --- a/docs/api/rsc/routeRSCServerRequest.md +++ b/docs/api/rsc/routeRSCServerRequest.md @@ -47,12 +47,17 @@ routeRSCServerRequest({ request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + nonce, + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( - , + , { + ...options, bootstrapScriptContent, formState: await payload.formState, } @@ -70,6 +75,7 @@ async function routeRSCServerRequest({ createFromReadableStream, renderHTML, hydrate = true, + nonce, }: { request: Request; serverResponse: Response; @@ -77,11 +83,13 @@ 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 ``` @@ -99,6 +107,10 @@ A Response or partial response generated by the [RSC](https://react.dev/referenc Whether to hydrate the server response with the RSC payload. Defaults to `true`. +### 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. + ### opts.renderHTML A function that renders the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react-router.unstable_RSCPayload.html) to HTML, usually using a [``](../rsc/RSCStaticRouter). diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index 6283213d99..0fee0f9e4e 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -385,7 +385,6 @@ The following options from `react-router.config.ts` are not currently supported - `presets` - `serverBundles` - `splitRouteModules` -- `subResourceIntegrity` ## RSC Data Mode @@ -868,7 +867,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 | null; basename: string | undefined; + clientVersion?: string; errors: Record | null; loaderData: Record; location: Location; @@ -253,7 +254,6 @@ export type RSCRenderPayload = { // matching on upward navigations. Only needed on the initial document request, // for SPA navigations the manifest call will handle these patches. patches?: Promise; - nonce?: string; formState?: ReactFormState; }; @@ -374,6 +374,8 @@ export type RouteDiscovery = * encoding the {@link unstable_RSCPayload}. * @param opts.loadServerAction Your `react-server-dom-xyz/server`'s * `loadServerAction` function, used to load a server action by ID. + * @param opts.clientVersion A version derived from the client build output used + * to detect stale clients during lazy route discovery. * @param opts.onError An optional error handler that will be called with any * errors that occur during the request processing. * @param opts.request The [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) @@ -397,6 +399,7 @@ export async function matchRSCServerRequest({ loadServerAction, decodeAction, decodeFormState, + clientVersion, onError, request, routes, @@ -410,6 +413,7 @@ export async function matchRSCServerRequest({ decodeFormState?: DecodeFormStateFunction; requestContext?: RouterContextProvider; loadServerAction?: LoadServerActionFunction; + clientVersion?: string; onError?: (error: unknown) => void; request: Request; routes: RSCRouteConfigEntry[]; @@ -464,6 +468,7 @@ export async function matchRSCServerRequest({ generateResponse, temporaryReferences, routeDiscovery, + clientVersion, ); return response; } @@ -511,6 +516,7 @@ export async function matchRSCServerRequest({ temporaryReferences, allowedActionOrigins, routeDiscovery, + clientVersion, ); // The front end uses this to know whether a 4xx/5xx status came from app code // or never reached the origin server @@ -531,6 +537,7 @@ async function generateManifestResponse( ) => Response, temporaryReferences: unknown, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ) { let url = new URL(request.url); if (url.toString().length > URL_LIMIT) { @@ -540,6 +547,18 @@ async function generateManifestResponse( }); } + if ( + clientVersion !== undefined && + clientVersion !== url.searchParams.get("version") + ) { + return new Response(null, { + status: 204, + headers: { + "X-Remix-Reload-Document": "true", + }, + }); + } + if (routeDiscovery?.mode === "initial") { let payload: RSCManifestPayload = { type: "manifest", @@ -814,6 +833,7 @@ async function generateRenderResponse( temporaryReferences: unknown, allowedActionOrigins: string[] | undefined, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ): Promise { // If this is a RR submission, we just want the `actionData` but don't want // to call any loaders or render any components back in the response - that @@ -952,6 +972,7 @@ async function generateRenderResponse( skipRevalidation, ctx.redirect?.headers, routeDiscovery, + clientVersion, ); }, }), @@ -1050,6 +1071,7 @@ async function generateStaticContextResponse( skipRevalidation: boolean, sideEffectRedirectHeaders: Headers | undefined, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ): Promise { statusCode = staticContext.statusCode ?? statusCode; @@ -1099,6 +1121,7 @@ async function generateStaticContextResponse( const baseRenderPayload: Omit = { type: "render", basename: staticContext.basename, + clientVersion, routeDiscovery: routeDiscovery ?? { mode: "lazy" }, actionData: staticContext.actionData, errors: staticContext.errors, 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} /> diff --git a/playground/rsc-vite-framework/app/entry.rsc.tsx b/playground/rsc-vite-framework/app/entry.rsc.tsx index 40d71d4de1..c6612493b4 100644 --- a/playground/rsc-vite-framework/app/entry.rsc.tsx +++ b/playground/rsc-vite-framework/app/entry.rsc.tsx @@ -15,6 +15,7 @@ import { import routes from "virtual:react-router/unstable_rsc/routes"; import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery"; import basename from "virtual:react-router/unstable_rsc/basename"; +import clientVersion from "virtual:react-router/unstable_rsc/client-version"; import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config"; export { unstable_reactRouterServeConfig }; @@ -33,6 +34,8 @@ export function fetchServer( decodeFormState, decodeReply, loadServerAction, + // Detect stale clients after a new deployment. + clientVersion, // The incoming request. request, requestContext, diff --git a/scripts/pr.ts b/scripts/pr.ts index c23cd79352..2a505670f7 100644 --- a/scripts/pr.ts +++ b/scripts/pr.ts @@ -32,6 +32,7 @@ import * as fs from "node:fs"; import * as util from "node:util"; +import { parseAllChangeFiles } from "./changes/changes.ts"; import { addPrLabels, closePr, @@ -96,6 +97,15 @@ pnpm run changes:add > Not every PR needs a change file — you can skip this step if the change is internal-only > (tests, tooling, docs)`; +const CHANGE_FILE_INVALID_COMMENT = `${CHANGE_FILE_MARKER} +### ❌ Invalid Change Files + +One or more change files are invalid. Run the following command locally for details: + +\`\`\`sh +pnpm changes:validate +\`\`\``; + const CLOSE_FEATURE_PR_COMMENT = `\ To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now asking that all new features go through the [Proposal/RFC process](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and that we don't open PRs until a proposal has been accepted and advanced to Stage 1. @@ -274,6 +284,22 @@ async function changeFileCheck(ctx: CheckContext): Promise { let body = CHANGE_FILE_MISSING_COMMENT; if (summaries.length > 0) { + let { valid } = parseAllChangeFiles(); + if (!valid) { + console.log("changeFileCheck: invalid change files found"); + return { + actions: [ + { + type: "upsert-sticky-comment", + marker: CHANGE_FILE_MARKER, + body: CHANGE_FILE_INVALID_COMMENT, + }, + ], + failureMessage: + "Change file validation failed - please run `pnpm changes:validate` locally for details", + }; + } + body = [ CHANGE_FILE_FOUND_COMMENT, "| Type | Change |",