Skip to content

Instantly share code, notes, and snippets.

@rphlmr
Last active August 15, 2023 22:31
Show Gist options
  • Save rphlmr/879a88561bd85bfb5857ef1cd25913ee to your computer and use it in GitHub Desktop.
Save rphlmr/879a88561bd85bfb5857ef1cd25913ee to your computer and use it in GitHub Desktop.
How I have implemented CSP nonce, based on https://github.com/remix-run/remix/issues/5162
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/
import crypto from "node:crypto";
import { PassThrough } from "node:stream";
import type { EntryContext } from "@remix-run/node";
import { Response } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { NonceProvider } from "~/utils/nonce-provider";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
) {
const nonce = crypto.randomBytes(16).toString("hex");
responseHeaders.set(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`,
);
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
nonce,
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
nonce,
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
nonce: string,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<NonceProvider value={nonce}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</NonceProvider>,
{
// Also set nonce for Suspense
nonce,
onAllReady() {
shellRendered = true;
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
nonce: string,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<NonceProvider value={nonce}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</NonceProvider>,
{
// Also set nonce for Suspense
nonce,
onShellReady() {
shellRendered = true;
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}
import * as React from "react";
// Source: https://github.com/remix-run/remix/issues/5162
// Why nonce is important: https://remix.run/docs/en/1.19.0/file-conventions/root, https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources
const NonceContext = React.createContext<string>("");
export const NonceProvider = NonceContext.Provider;
export const useNonce = () => React.useContext(NonceContext);
import { cssBundleHref } from "@remix-run/css-bundle";
import {
type DataFunctionArgs,
type LinksFunction,
type V2_MetaFunction,
} from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import tailwindStylesheetUrl from "~/styles/tailwind.css";
import { useNonce } from "~/utils/nonce-provider";
export const links: LinksFunction = () => [
{ rel: "preload", href: tailwindStylesheetUrl, as: "style" },
{ rel: "stylesheet", href: tailwindStylesheetUrl, as: "style" },
...(cssBundleHref
? [
{ rel: "preload", href: cssBundleHref, as: "style" },
{ rel: "stylesheet", href: cssBundleHref },
]
: []),
];
export default function App() {
const nonce = useNonce();
const { ENV } = useLoaderData<typeof loader>();
return (
<html lang="en" className="h-full">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,maximum-scale=1.0"
/>
<Meta />
<Links />
</head>
<body className="relative h-full overscroll-y-none">
<Outlet />
<Toaster />
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
<LiveReload nonce={nonce} />
</body>
</html>
);
}
@rphlmr
Copy link
Author

rphlmr commented Aug 14, 2023

Now, every time you have a script tag, set nonce to it. (fromuseNonce hook)

<script
	nonce={nonce}
	dangerouslySetInnerHTML={{
		__html: `window.ENV = ${JSON.stringify(ENV)}`,
	}}
/>

@dev-xo
Copy link

dev-xo commented Aug 15, 2023

Really helpful @rphlmr! Thank you!

@rphlmr
Copy link
Author

rphlmr commented Aug 15, 2023

@dev-xo
Copy link

dev-xo commented Aug 15, 2023

Works like a charm @rphlmr! Thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment