Skip to content

Commit

Permalink
Allow a nonce to be set on single fetch stream transfer inline scripts (
Browse files Browse the repository at this point in the history
#9364)

Co-authored-by: Matt Brophy <matt@brophy.org>
  • Loading branch information
haines and brophdawg11 committed May 6, 2024
1 parent 3d8eeaf commit 142f47b
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-vans-brake.md
@@ -0,0 +1,5 @@
---
"@remix-run/react": patch
---

Allow a nonce to be set on single fetch stream transfer inline scripts
6 changes: 6 additions & 0 deletions docs/guides/single-fetch.md
Expand Up @@ -344,6 +344,10 @@ It's best to try to avoid using the `response` stub _and also_ returning a `Resp
- The `Response` instance status will take priority over any `response` stub status
- Headers operations on the `response` stub `headers` will be re-played on the returned `Response` headers instance

### Inline Scripts

The `<RemixServer>` component renders inline scripts that handle the streaming data on the client side. If you have a [content security policy for scripts][csp] with [nonce-sources][csp-nonce], you can use `<RemixServer nonce>` to pass through the nonce to these `<script>` tags.

[future-flags]: ../file-conventions/remix-config#future
[should-revalidate]: ../route/should-revalidate
[entry-server]: ../file-conventions/entry.server
Expand All @@ -361,3 +365,5 @@ It's best to try to avoid using the `response` stub _and also_ returning a `Resp
[resource-routes]: ../guides/resource-routes
[responsestub]: #headers
[streaming-format]: #streaming-data-format
[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
[csp-nonce]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
92 changes: 92 additions & 0 deletions integration/single-fetch-test.ts
Expand Up @@ -2542,4 +2542,96 @@ test.describe("single-fetch", () => {
expect(await app.page.locator("nav link[as='fetch']").count()).toEqual(0);
});
});

test("supports nonce on streaming script tags", async ({ page }) => {
let fixture = await createFixture({
config: {
future: {
unstable_singleFetch: true,
},
},
files: {
...files,
"app/root.tsx": js`
import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
export function loader() {
return {
message: "ROOT",
};
}
export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<Scripts nonce="the-nonce" />
</body>
</html>
);
}
`,
"app/entry.server.tsx": js`
import { PassThrough } from "node:stream";
import type { EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToPipeableStream } from "react-dom/server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
const { pipe } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} nonce="the-nonce" />,
{
onShellReady() {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
},
}
);
});
}
`,
},
});
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/data", true);
let scripts = await page.$$("script");
expect(scripts.length).toBe(6);
let remixScriptsCount = 0;
for (let script of scripts) {
let content = await script.innerHTML();
if (content.includes("window.__remix")) {
remixScriptsCount++;
expect(await script.getAttribute("nonce")).toEqual("the-nonce");
}
}
expect(remixScriptsCount).toBe(4);
});
});
3 changes: 3 additions & 0 deletions packages/remix-react/server.tsx
Expand Up @@ -15,6 +15,7 @@ export interface RemixServerProps {
context: EntryContext;
url: string | URL;
abortDelay?: number;
nonce?: string;
}

/**
Expand All @@ -26,6 +27,7 @@ export function RemixServer({
context,
url,
abortDelay,
nonce,
}: RemixServerProps): ReactElement {
if (typeof url === "string") {
url = new URL(url);
Expand Down Expand Up @@ -101,6 +103,7 @@ export function RemixServer({
identifier={0}
reader={context.serverHandoffStream.getReader()}
textDecoder={new TextDecoder()}
nonce={nonce}
/>
</React.Suspense>
) : null}
Expand Down
5 changes: 5 additions & 0 deletions packages/remix-react/single-fetch.tsx
Expand Up @@ -29,6 +29,7 @@ interface StreamTransferProps {
identifier: number;
reader: ReadableStreamDefaultReader<Uint8Array>;
textDecoder: TextDecoder;
nonce?: string;
}

// StreamTransfer recursively renders down chunks of the `serverHandoffStream`
Expand All @@ -38,6 +39,7 @@ export function StreamTransfer({
identifier,
reader,
textDecoder,
nonce,
}: StreamTransferProps) {
// If the user didn't render the <Scripts> component then we don't have to
// bother streaming anything in
Expand Down Expand Up @@ -74,6 +76,7 @@ export function StreamTransfer({
let { done, value } = promise.result;
let scriptTag = value ? (
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.__remixContext.streamController.enqueue(${escapeHtml(
JSON.stringify(value)
Expand All @@ -87,6 +90,7 @@ export function StreamTransfer({
<>
{scriptTag}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.__remixContext.streamController.close();`,
}}
Expand All @@ -103,6 +107,7 @@ export function StreamTransfer({
identifier={identifier + 1}
reader={reader}
textDecoder={textDecoder}
nonce={nonce}
/>
</React.Suspense>
</>
Expand Down

0 comments on commit 142f47b

Please sign in to comment.