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:
2026-10-15 23:44:56 +02:00
parent a5539d18d7
commit 4afc510fcc
2 changed files with 45 additions and 2 deletions
+35
View File
@@ -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({