Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow a nonce to be set on single fetch stream transfer inline scripts #9364

Merged
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
5 changes: 5 additions & 0 deletions .changeset/nasty-vans-brake.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -2467,4 +2467,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading