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"
/>
-
-
+
+