diff --git a/apps/web/app/entry.server.tsx b/apps/web/app/entry.server.tsx index 50c7be7..c168cb2 100644 --- a/apps/web/app/entry.server.tsx +++ b/apps/web/app/entry.server.tsx @@ -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( , { + nonce, [readyOption]() { shellRendered = true; const body = new PassThrough({ diff --git a/apps/web/app/root.tsx b/apps/web/app/root.tsx index 2a67ae4..29e2d5e 100644 --- a/apps/web/app/root.tsx +++ b/apps/web/app/root.tsx @@ -70,6 +70,11 @@ const FONT_PRELOADS: Readonly> = { ], }; +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("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" />