Skip to content
Open
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
53 changes: 53 additions & 0 deletions docs/how-to/react-server-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,60 @@ createFromReadableStream<RSCPayload>(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<Response> {
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(
<RSCStaticRouter
getPayload={getPayload}
nonce={options.nonce}
/>,
{
...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 `<Links>` and `<ScrollRestoration>`.

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
Expand Down
9 changes: 8 additions & 1 deletion docs/how-to/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: Security

# Security

[MODES: framework]
[MODES: framework, data]

<br/>
<br/>
Expand All @@ -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 `<script>` elements rendered in your HTML.

Add a nonce to these two spots in [`entry.server.tsx`][entryserver]:
Expand All @@ -22,6 +24,10 @@ Add a nonce to these two spots in [`entry.server.tsx`][entryserver]:
- If those components specify their own `nonce` prop, it will override the `ServerRouter` value
- The `nonce` options of [`renderToPipeableStream`][renderToPipeableStream]/[`renderToReadableStream`][renderToReadableStream]

### RSC Framework and RSC Data Mode

For RSC Framework and RSC Data Mode, generate the nonce in `entry.ssr.tsx` and pass it to `routeRSCServerRequest`, `RSCStaticRouter`, and the CSP response header. See the [RSC Content Security Policy nonce guide][rsc-csp]. The nonce is only needed while generating the HTML document; it should not be included in the RSC payload or passed to `matchRSCServerRequest`.

[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
[entryserver]: ../api/framework-conventions/entry.server.tsx
[nonce]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce
Expand All @@ -30,3 +36,4 @@ Add a nonce to these two spots in [`entry.server.tsx`][entryserver]:
[scripts]: ../api/components/Scripts
[scrollrestoration]: ../api/components/ScrollRestoration
[serverrouter]: ../api/framework-routers/ServerRouter
[rsc-csp]: ./react-server-components#content-security-policy-nonces
196 changes: 196 additions & 0 deletions integration/rsc-nonce-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { expect, type Page } from "@playwright/test";
import getPort from "get-port";

import { js } from "./helpers/create-fixture.js";
import { test } from "./helpers/vite.js";
import { implementations, setupRscTest } from "./rsc/utils.js";

async function expectNonceSupport(page: Page, nonce: string) {
const scripts = page.locator("script");
const count = await scripts.count();
expect(count).toBeGreaterThan(0);
for (let index = 0; index < count; index++) {
expect(
await scripts
.nth(index)
.evaluate((script: HTMLScriptElement) => script.nonce),
).toBe(nonce);
}

await page.getByRole("button", { name: "Count: 0" }).click();
await expect(page.getByRole("button", { name: "Count: 1" })).toBeVisible();
}

test.describe("RSC CSP nonces", () => {
test.describe("RSC Framework", () => {
test("adds the nonce to document scripts and hydrates under a strict CSP", async ({
page,
vitePreview,
}) => {
const { port } = await vitePreview(
async () => ({
"app/entry.ssr.tsx": js`
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import { renderToReadableStream } from "react-dom/server.edge";
import {
unstable_routeRSCServerRequest as routeRSCServerRequest,
unstable_RSCStaticRouter as RSCStaticRouter,
} from "react-router";

export async function generateHTML(
request: Request,
serverResponse: Response,
) {
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 renderToReadableStream(
<RSCStaticRouter
getPayload={getPayload}
nonce={options.nonce}
/>,
{
...options,
bootstrapScriptContent,
formState: await payload.formState,
signal: request.signal,
},
);
},
});
response.headers.set(
"Content-Security-Policy",
"script-src 'self' 'nonce-" + nonce + "'",
);
return response;
}
`,
"app/routes/_index.tsx": js`
"use client";

import { useState } from "react";

export default function Index() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
`,
}),
"rsc-vite-framework",
);

const response = await page.goto(`http://localhost:${port}`);
const policy = response?.headers()["content-security-policy"];
const nonce = policy?.match(/'nonce-([^']+)'/)?.[1];
expect(nonce).toBeTruthy();
await expectNonceSupport(page, nonce!);
});
});

implementations.forEach((implementation) => {
test.describe(`RSC Data (${implementation.name})`, () => {
let port: number;
let stop: (() => void) | undefined;

test.beforeAll(async () => {
port = await getPort();
stop = await setupRscTest({
implementation,
port,
files: {
"src/entry.ssr.tsx": js`
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import { renderToReadableStream } from "react-dom/server.edge";
import {
unstable_routeRSCServerRequest as routeRSCServerRequest,
unstable_RSCStaticRouter as RSCStaticRouter,
} from "react-router";

export default async function handler(
request: Request,
serverResponse: Response,
) {
const nonce = crypto.randomUUID();
const bootstrapScriptContent =
await import.meta.viteRsc.loadBootstrapScriptContent("index");
const response = await routeRSCServerRequest({
request,
serverResponse,
createFromReadableStream,
nonce,
async renderHTML(getPayload, options) {
const payload = getPayload();
return renderToReadableStream(
<RSCStaticRouter
getPayload={getPayload}
nonce={options.nonce}
/>,
{
...options,
bootstrapScriptContent,
signal: request.signal,
formState: await payload.formState,
},
);
},
});
response.headers.set(
"Content-Security-Policy",
"script-src 'self' 'nonce-" + nonce + "'",
);
return response;
}
`,
"src/routes/home.client.tsx": js`
"use client";

import { useState } from "react";

export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
`,
"src/routes/home.tsx": js`
import Counter from "./home.client";

export default function HomeRoute() {
return <Counter />;
}
`,
},
});
});

test.afterAll(() => {
stop?.();
});

test("adds the nonce to document scripts and hydrates under a strict CSP", async ({
page,
}) => {
const response = await page.goto(`http://localhost:${port}`);
const policy = response?.headers()["content-security-policy"];
const nonce = policy?.match(/'nonce-([^']+)'/)?.[1];
expect(nonce).toBeTruthy();
await expectNonceSupport(page, nonce!);
});
});
});
});
32 changes: 32 additions & 0 deletions packages/react-router/.changes/unstable.rsc-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Add CSP nonce support to RSC document rendering

- Add `nonce` options to `unstable_routeRSCServerRequest` and `unstable_RSCStaticRouter`
- Forward the nonce to the HTML renderer and apply it to injected RSC payload scripts and nonce-aware framework components

To adopt nonce-based CSP, update your `entry.ssr.tsx` (run `react-router reveal entry.ssr` first in RSC Framework Mode) to generate a fresh nonce for each request. Pass it to `routeRSCServerRequest`, spread the `renderHTML` options into React's HTML renderer, pass `options.nonce` to `RSCStaticRouter`, and use the same nonce in the `Content-Security-Policy` response header:

```tsx
const nonce = crypto.randomUUID();
const response = await routeRSCServerRequest({
request,
serverResponse,
createFromReadableStream,
nonce,
async renderHTML(getPayload, options) {
const payload = getPayload();
return renderHTMLToReadableStream(
<RSCStaticRouter getPayload={getPayload} nonce={options.nonce} />,
{
...options,
bootstrapScriptContent,
formState: await payload.formState,
signal: request.signal,
},
);
},
});
response.headers.set(
"Content-Security-Policy",
`script-src 'self' 'nonce-${nonce}'`,
);
```
Loading