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
3 changes: 2 additions & 1 deletion docs/api/components/Links.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ function Links({ nonce, crossOrigin }: LinksProps): React.JSX.Element

A [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce)
attribute to render on the [`<link>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link)
element
element. If not provided in Framework Mode, it will default to any
``<ServerRouter nonce>`` prop.

### crossOrigin

Expand Down
8 changes: 6 additions & 2 deletions docs/api/components/ScrollRestoration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export default function Root() {
}
```

This component renders an inline `<script>` to prevent scroll flashing. The `nonce` prop will be passed down to the script tag to allow CSP nonce usage.
This component renders an inline `<script>` to prevent scroll flashing. The
`nonce` prop will be passed down to the script tag to allow CSP nonce usage.
If not provided in Framework Mode, it will default to any
[`<ServerRouter nonce>`](../framework-routers/ServerRouter) prop.

```tsx
<ScrollRestoration nonce={cspNonce} />
Expand Down Expand Up @@ -80,7 +83,8 @@ that later navigations to prior paths will restore the scroll. Defaults to

A [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce)
attribute to render on the [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script)
element
element. If not provided in Framework Mode, it will default to any
``<ServerRouter nonce>`` prop.

### storageKey

Expand Down
5 changes: 4 additions & 1 deletion docs/api/framework-routers/ServerRouter.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ needed for rendering.
### nonce

An optional `nonce` for [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
compliance, used to allow inline scripts to run safely.
compliance. This is applied to inline scripts rendered by React Router and
used as the default for nonce-aware components such as ``<Links>``,
``<Scripts>``, and ``<ScrollRestoration>``
when they do not provide their own `nonce`.

### url

Expand Down
14 changes: 8 additions & 6 deletions docs/how-to/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ This is by no means a comprehensive guide, but React Router provides features to

## `Content-Security-Policy`

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. This must be specified on any API that generates inline scripts, including:
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.

- [`<Scripts nonce>`][scripts] (`root.tsx`)
- [`<ScrollRestoration nonce>`][scrollrestoration] (`root.tsx`)
- [`<ServerRouter nonce>`][serverrouter] (`entry.server.tsx`)
- [`renderToPipeableStream(..., { nonce })`][renderToPipeableStream] (`entry.server.tsx`)
- [`renderToReadableStream(..., { nonce })`][renderToReadableStream] (`entry.server.tsx`)
Add a nonce to these two spots in [`entry.server.tsx`][entryserver]:

- The [`<ServerRouter nonce>`][serverrouter] prop
- This will be proxied along through React Context and used for other Framework Mode components that output `nonce`-aware elements, including [`<Scripts>`][scripts], [`<ScrollRestoration>`][scrollrestoration]
- If those components specify their own `nonce` prop, it will override the `ServerRouter` value
- The `nonce` options of [`renderToPipeableStream`][renderToPipeableStream]/[`renderToReadableStream`][renderToReadableStream]

[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
[renderToPipeableStream]: https://react.dev/reference/react-dom/server/renderToPipeableStream
[renderToReadableStream]: https://react.dev/reference/react-dom/server/renderToReadableStream
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the `ServerRouter` nonce for nonce-aware SSR components when they don't provide their own value so strict CSP pages can load them.
55 changes: 55 additions & 0 deletions packages/react-router/__tests__/dom/scroll-restoration-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,32 @@ describe(`ScrollRestoration`, () => {
expect(script instanceof HTMLScriptElement).toBe(true);
});

it("uses the FrameworkContext nonce when one is not provided", () => {
let router = createMemoryRouter([
{
id: "root",
path: "/",
element: (
<>
<Outlet />
<ScrollRestoration data-testid="scroll-script" />
<Scripts />
</>
),
},
]);

render(
<FrameworkContext.Provider
value={mockFrameworkContext({ nonce: "server-nonce" })}
>
<RouterProvider router={router} />
</FrameworkContext.Provider>,
);
let script = screen.getByTestId("scroll-script");
expect(script).toHaveAttribute("nonce", "server-nonce");
});

it("should pass props to <script>", () => {
let router = createMemoryRouter([
{
Expand Down Expand Up @@ -261,6 +287,35 @@ describe(`ScrollRestoration`, () => {
expect(script).toHaveAttribute("crossorigin", "anonymous");
});

it("prefers an explicit nonce over the FrameworkContext nonce", () => {
let router = createMemoryRouter([
{
id: "root",
path: "/",
element: (
<>
<Outlet />
<ScrollRestoration
data-testid="scroll-script"
nonce="explicit-nonce"
/>
<Scripts />
</>
),
},
]);

render(
<FrameworkContext.Provider
value={mockFrameworkContext({ nonce: "server-nonce" })}
>
<RouterProvider router={router} />
</FrameworkContext.Provider>,
);
let script = screen.getByTestId("scroll-script");
expect(script).toHaveAttribute("nonce", "explicit-nonce");
});

it("should restore scroll position", () => {
let scrollToMock = jest.spyOn(window, "scrollTo");
let router = createMemoryRouter([
Expand Down
150 changes: 150 additions & 0 deletions packages/react-router/__tests__/dom/ssr/components-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,46 @@ describe("<HydratedRouter>", () => {
});

describe("<Links />", () => {
it("uses the FrameworkContext nonce when one is not provided", () => {
let context = mockFrameworkContext({
criticalCss: ".critical { color: red; }",
nonce: "server-nonce",
});

let { container } = render(
<DataRouterStateContext.Provider
value={{ matches: [], errors: null } as any}
>
<FrameworkContext.Provider value={context}>
<Links />
</FrameworkContext.Provider>
</DataRouterStateContext.Provider>,
);

let style = container.querySelector("style");
expect(style).toHaveAttribute("nonce", "server-nonce");
});

it("prefers an explicit nonce over the FrameworkContext nonce", () => {
let context = mockFrameworkContext({
criticalCss: ".critical { color: red; }",
nonce: "server-nonce",
});

let { container } = render(
<DataRouterStateContext.Provider
value={{ matches: [], errors: null } as any}
>
<FrameworkContext.Provider value={context}>
<Links nonce="explicit-nonce" />
</FrameworkContext.Provider>
</DataRouterStateContext.Provider>,
);

let style = container.querySelector("style");
expect(style).toHaveAttribute("nonce", "explicit-nonce");
});

it("renders critical css with nonce", () => {
let context = mockFrameworkContext({
criticalCss: ".critical { color: red; }",
Expand Down Expand Up @@ -385,6 +425,60 @@ describe("<Links />", () => {
let link = container.querySelector("link[href='/style.css']");
expect(link).toHaveAttribute("nonce", "test-nonce");
});

it("propagates the FrameworkContext nonce to route links", () => {
let context = mockFrameworkContext({
nonce: "server-nonce",
routeModules: {
root: {
default: () => null,
links: () => [{ rel: "stylesheet", href: "/style.css" }],
},
},
manifest: {
routes: {
root: {
id: "root",
module: "root.js",
hasLoader: false,
hasAction: false,
hasErrorBoundary: false,
hasClientAction: false,
hasClientLoader: false,
hasClientMiddleware: false,
clientActionModule: undefined,
clientLoaderModule: undefined,
clientMiddlewareModule: undefined,
hydrateFallbackModule: undefined,
},
},
entry: { imports: [], module: "" },
url: "",
version: "",
},
});

let { container } = render(
<DataRouterStateContext.Provider
value={
{
matches: [
{
route: { id: "root" },
},
],
} as any
}
>
<FrameworkContext.Provider value={context}>
<Links />
</FrameworkContext.Provider>
</DataRouterStateContext.Provider>,
);

let link = container.querySelector("link[href='/style.css']");
expect(link).toHaveAttribute("nonce", "server-nonce");
});
});

describe("<Scripts />", () => {
Expand Down Expand Up @@ -463,6 +557,62 @@ describe("<Scripts />", () => {
),
).toHaveAttribute("nonce", "test-nonce");
});

it("propagates the ServerRouter nonce to default HydrateFallback scripts when a route has a clientLoader without a HydrateFallback", async () => {
let staticHandlerContext = await createStaticHandler([{ path: "/" }]).query(
new Request("http://localhost/"),
);

invariant(
!(staticHandlerContext instanceof Response),
"Expected a context",
);

let context = mockEntryContext({
manifest: {
routes: {
root: {
// No server loader and a hydrating clientLoader with no
// HydrateFallback => the default HydrateFallback is rendered on the
// server, emitting `<Scripts>` (and a dev console script) inline.
hasLoader: false,
hasClientLoader: true,
hasAction: false,
hasErrorBoundary: false,
id: "root",
module: "root.js",
path: "/",
},
},
entry: {
imports: [],
module: "entry.js",
},
url: "manifest.js",
version: "",
},
routeModules: {
root: {
default: () => <h1>Root</h1>,
clientLoader: Object.assign(() => null, { hydrate: true as const }),
},
},
});

let { container } = render(
<ServerRouter
context={context}
url="http://localhost/"
nonce="test-nonce"
/>,
);

let scripts = container.ownerDocument.querySelectorAll("script");
expect(scripts.length).toBeGreaterThan(0);
scripts.forEach((script) => {
expect(script).toHaveAttribute("nonce", "test-nonce");
});
});
});

describe("usePrefetchBehavior", () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/react-router/lib/dom/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2052,7 +2052,10 @@ export type ScrollRestorationProps = ScriptsProps & {
* }
* ```
*
* This component renders an inline `<script>` to prevent scroll flashing. The `nonce` prop will be passed down to the script tag to allow CSP nonce usage.
* This component renders an inline `<script>` to prevent scroll flashing. The
* `nonce` prop will be passed down to the script tag to allow CSP nonce usage.
* If not provided in Framework Mode, it will default to any
* {@link ServerRouter | `<ServerRouter nonce>`} prop.
*
* ```tsx
* <ScrollRestoration nonce={cspNonce} />
Expand Down Expand Up @@ -2125,6 +2128,10 @@ export function ScrollRestoration({
}
}).toString();

if (props.nonce == null && remixContext?.nonce) {
props.nonce = remixContext.nonce;
}

return (
<script
{...props}
Expand Down
Loading