Last active
August 15, 2023 22:31
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Works like a charm @rphlmr! Thanks a lot!