fix(web): add nonce-based CSP
Generate a per-response nonce for SSR scripts instead of permitting arbitrary inline scripts. Allow only required analytics and configured CMS media origins.
This commit is contained in:
@@ -20,6 +20,7 @@ import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
@@ -32,6 +33,34 @@ function contentLanguage(request: Request): string {
|
||||
}
|
||||
}
|
||||
|
||||
function readCspNonce(routerContext: EntryContext): string | undefined {
|
||||
const rootData = routerContext.staticHandlerContext.loaderData["root"] as
|
||||
| { nonce?: unknown }
|
||||
| undefined;
|
||||
return typeof rootData?.nonce === "string" ? rootData.nonce : undefined;
|
||||
}
|
||||
|
||||
function contentSecurityPolicy(nonce: string): string {
|
||||
const { STRAPI_URL, isProduction } = getServerEnv();
|
||||
const mediaOrigin = new URL(STRAPI_URL).origin;
|
||||
const directives = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}' https://www.googletagmanager.com`,
|
||||
"script-src-attr 'none'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
|
||||
`media-src 'self' ${mediaOrigin} data: blob:`,
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
...(isProduction ? ["upgrade-insecure-requests"] : []),
|
||||
];
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
@@ -39,6 +68,11 @@ export default function handleRequest(
|
||||
routerContext: EntryContext,
|
||||
loadContext: RouterContextProvider,
|
||||
) {
|
||||
const nonce = readCspNonce(routerContext);
|
||||
if (nonce) {
|
||||
responseHeaders.set("Content-Security-Policy", contentSecurityPolicy(nonce));
|
||||
}
|
||||
|
||||
// https://httpwg.org/specs/rfc9110.html#HEAD
|
||||
if (request.method.toUpperCase() === "HEAD") {
|
||||
return new Response(null, {
|
||||
@@ -68,6 +102,7 @@ export default function handleRequest(
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
nonce,
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough({
|
||||
|
||||
+10
-2
@@ -70,6 +70,11 @@ const FONT_PRELOADS: Readonly<Record<Locale, readonly string[]>> = {
|
||||
],
|
||||
};
|
||||
|
||||
function createCspNonce(): string {
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved once per request. Every route can read the origin and the locale
|
||||
* off this loader via `matches[0].data` in its own `meta`.
|
||||
@@ -88,6 +93,7 @@ export function loader({ request }: Route.LoaderArgs) {
|
||||
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
|
||||
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
||||
theme: readTheme(request.headers.get("Cookie")),
|
||||
nonce: createCspNonce(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,6 +163,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
surface in either theme. */
|
||||
const rootData = useRouteLoaderData<typeof loader>("root");
|
||||
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
|
||||
const nonce = rootData?.nonce;
|
||||
|
||||
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
|
||||
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
|
||||
@@ -208,6 +215,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-TJT6NM2DMT"
|
||||
/>
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
@@ -243,8 +251,8 @@ gtag('config', 'G-TJT6NM2DMT');`,
|
||||
linkGroups={footerLinkGroups}
|
||||
/>
|
||||
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ScrollRestoration nonce={nonce} />
|
||||
<Scripts nonce={nonce} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user