Skip to content

Commit c26e431

Browse files
authored
feat(rsc): support CSP nonces in document rendering (#15320)
1 parent 6286f90 commit c26e431

8 files changed

Lines changed: 424 additions & 14 deletions

File tree

docs/how-to/react-server-components.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,60 @@ createFromReadableStream<RSCPayload>(getRSCStream()).then(
868868
);
869869
```
870870

871+
## Content Security Policy nonces
872+
873+
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.
874+
875+
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:
876+
877+
```tsx filename=app/entry.ssr.tsx
878+
export async function generateHTML(
879+
request: Request,
880+
serverResponse: Response,
881+
): Promise<Response> {
882+
const nonce = crypto.randomUUID();
883+
884+
const response = await routeRSCServerRequest({
885+
request,
886+
serverResponse,
887+
createFromReadableStream,
888+
nonce,
889+
async renderHTML(getPayload, options) {
890+
const payload = getPayload();
891+
const bootstrapScriptContent =
892+
await import.meta.viteRsc.loadBootstrapScriptContent(
893+
"index",
894+
);
895+
896+
return renderHTMLToReadableStream(
897+
<RSCStaticRouter
898+
getPayload={getPayload}
899+
nonce={options.nonce}
900+
/>,
901+
{
902+
...options,
903+
bootstrapScriptContent,
904+
formState: await payload.formState,
905+
signal: request.signal,
906+
},
907+
);
908+
},
909+
});
910+
911+
response.headers.set(
912+
"Content-Security-Policy",
913+
`script-src 'self' 'nonce-${nonce}'`,
914+
);
915+
return response;
916+
}
917+
```
918+
919+
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>`.
920+
921+
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.
922+
871923
[picking-a-mode]: ../start/modes
924+
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
872925
[react-server-components-doc]: https://react.dev/reference/rsc/server-components
873926
[react-server-functions-doc]: https://react.dev/reference/rsc/server-functions
874927
[use-client-docs]: https://react.dev/reference/rsc/use-client

docs/how-to/security.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: Security
44

55
# Security
66

7-
[MODES: framework]
7+
[MODES: framework, data]
88

99
<br/>
1010
<br/>
@@ -13,6 +13,8 @@ This is by no means a comprehensive guide, but React Router provides features to
1313

1414
## `Content-Security-Policy`
1515

16+
### Framework Mode without RSC
17+
1618
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.
1719

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

27+
### RSC Framework and RSC Data Mode
28+
29+
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`.
30+
2531
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP
2632
[entryserver]: ../api/framework-conventions/entry.server.tsx
2733
[nonce]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce
@@ -30,3 +36,4 @@ Add a nonce to these two spots in [`entry.server.tsx`][entryserver]:
3036
[scripts]: ../api/components/Scripts
3137
[scrollrestoration]: ../api/components/ScrollRestoration
3238
[serverrouter]: ../api/framework-routers/ServerRouter
39+
[rsc-csp]: ./react-server-components#content-security-policy-nonces

integration/rsc-nonce-test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { expect, type Page } from "@playwright/test";
2+
import getPort from "get-port";
3+
4+
import { js } from "./helpers/create-fixture.js";
5+
import { test } from "./helpers/vite.js";
6+
import { implementations, setupRscTest } from "./rsc/utils.js";
7+
8+
async function expectNonceSupport(page: Page, nonce: string) {
9+
const scripts = page.locator("script");
10+
const count = await scripts.count();
11+
expect(count).toBeGreaterThan(0);
12+
for (let index = 0; index < count; index++) {
13+
expect(
14+
await scripts
15+
.nth(index)
16+
.evaluate((script: HTMLScriptElement) => script.nonce),
17+
).toBe(nonce);
18+
}
19+
20+
await page.getByRole("button", { name: "Count: 0" }).click();
21+
await expect(page.getByRole("button", { name: "Count: 1" })).toBeVisible();
22+
}
23+
24+
test.describe("RSC CSP nonces", () => {
25+
test.describe("RSC Framework", () => {
26+
test("adds the nonce to document scripts and hydrates under a strict CSP", async ({
27+
page,
28+
vitePreview,
29+
}) => {
30+
const { port } = await vitePreview(
31+
async () => ({
32+
"app/entry.ssr.tsx": js`
33+
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
34+
import { renderToReadableStream } from "react-dom/server.edge";
35+
import {
36+
unstable_routeRSCServerRequest as routeRSCServerRequest,
37+
unstable_RSCStaticRouter as RSCStaticRouter,
38+
} from "react-router";
39+
40+
export async function generateHTML(
41+
request: Request,
42+
serverResponse: Response,
43+
) {
44+
const nonce = crypto.randomUUID();
45+
const response = await routeRSCServerRequest({
46+
request,
47+
serverResponse,
48+
createFromReadableStream,
49+
nonce,
50+
async renderHTML(getPayload, options) {
51+
const payload = getPayload();
52+
const bootstrapScriptContent =
53+
await import.meta.viteRsc.loadBootstrapScriptContent("index");
54+
55+
return renderToReadableStream(
56+
<RSCStaticRouter
57+
getPayload={getPayload}
58+
nonce={options.nonce}
59+
/>,
60+
{
61+
...options,
62+
bootstrapScriptContent,
63+
formState: await payload.formState,
64+
signal: request.signal,
65+
},
66+
);
67+
},
68+
});
69+
response.headers.set(
70+
"Content-Security-Policy",
71+
"script-src 'self' 'nonce-" + nonce + "'",
72+
);
73+
return response;
74+
}
75+
`,
76+
"app/routes/_index.tsx": js`
77+
"use client";
78+
79+
import { useState } from "react";
80+
81+
export default function Index() {
82+
const [count, setCount] = useState(0);
83+
return (
84+
<button onClick={() => setCount(count + 1)}>
85+
Count: {count}
86+
</button>
87+
);
88+
}
89+
`,
90+
}),
91+
"rsc-vite-framework",
92+
);
93+
94+
const response = await page.goto(`http://localhost:${port}`);
95+
const policy = response?.headers()["content-security-policy"];
96+
const nonce = policy?.match(/'nonce-([^']+)'/)?.[1];
97+
expect(nonce).toBeTruthy();
98+
await expectNonceSupport(page, nonce!);
99+
});
100+
});
101+
102+
implementations.forEach((implementation) => {
103+
test.describe(`RSC Data (${implementation.name})`, () => {
104+
let port: number;
105+
let stop: (() => void) | undefined;
106+
107+
test.beforeAll(async () => {
108+
port = await getPort();
109+
stop = await setupRscTest({
110+
implementation,
111+
port,
112+
files: {
113+
"src/entry.ssr.tsx": js`
114+
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
115+
import { renderToReadableStream } from "react-dom/server.edge";
116+
import {
117+
unstable_routeRSCServerRequest as routeRSCServerRequest,
118+
unstable_RSCStaticRouter as RSCStaticRouter,
119+
} from "react-router";
120+
121+
export default async function handler(
122+
request: Request,
123+
serverResponse: Response,
124+
) {
125+
const nonce = crypto.randomUUID();
126+
const bootstrapScriptContent =
127+
await import.meta.viteRsc.loadBootstrapScriptContent("index");
128+
const response = await routeRSCServerRequest({
129+
request,
130+
serverResponse,
131+
createFromReadableStream,
132+
nonce,
133+
async renderHTML(getPayload, options) {
134+
const payload = getPayload();
135+
return renderToReadableStream(
136+
<RSCStaticRouter
137+
getPayload={getPayload}
138+
nonce={options.nonce}
139+
/>,
140+
{
141+
...options,
142+
bootstrapScriptContent,
143+
signal: request.signal,
144+
formState: await payload.formState,
145+
},
146+
);
147+
},
148+
});
149+
response.headers.set(
150+
"Content-Security-Policy",
151+
"script-src 'self' 'nonce-" + nonce + "'",
152+
);
153+
return response;
154+
}
155+
`,
156+
"src/routes/home.client.tsx": js`
157+
"use client";
158+
159+
import { useState } from "react";
160+
161+
export default function Counter() {
162+
const [count, setCount] = useState(0);
163+
return (
164+
<button onClick={() => setCount(count + 1)}>
165+
Count: {count}
166+
</button>
167+
);
168+
}
169+
`,
170+
"src/routes/home.tsx": js`
171+
import Counter from "./home.client";
172+
173+
export default function HomeRoute() {
174+
return <Counter />;
175+
}
176+
`,
177+
},
178+
});
179+
});
180+
181+
test.afterAll(() => {
182+
stop?.();
183+
});
184+
185+
test("adds the nonce to document scripts and hydrates under a strict CSP", async ({
186+
page,
187+
}) => {
188+
const response = await page.goto(`http://localhost:${port}`);
189+
const policy = response?.headers()["content-security-policy"];
190+
const nonce = policy?.match(/'nonce-([^']+)'/)?.[1];
191+
expect(nonce).toBeTruthy();
192+
await expectNonceSupport(page, nonce!);
193+
});
194+
});
195+
});
196+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Add CSP nonce support to RSC document rendering
2+
3+
- Add `nonce` options to `unstable_routeRSCServerRequest` and `unstable_RSCStaticRouter`
4+
- Forward the nonce to the HTML renderer and apply it to injected RSC payload scripts and nonce-aware framework components
5+
6+
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:
7+
8+
```tsx
9+
const nonce = crypto.randomUUID();
10+
const response = await routeRSCServerRequest({
11+
request,
12+
serverResponse,
13+
createFromReadableStream,
14+
nonce,
15+
async renderHTML(getPayload, options) {
16+
const payload = getPayload();
17+
return renderHTMLToReadableStream(
18+
<RSCStaticRouter getPayload={getPayload} nonce={options.nonce} />,
19+
{
20+
...options,
21+
bootstrapScriptContent,
22+
formState: await payload.formState,
23+
signal: request.signal,
24+
},
25+
);
26+
},
27+
});
28+
response.headers.set(
29+
"Content-Security-Policy",
30+
`script-src 'self' 'nonce-${nonce}'`,
31+
);
32+
```

0 commit comments

Comments
 (0)