From e855ea07ab36564be70c0dacca3f06a367058ddf Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:54:10 +0100 Subject: [PATCH 1/6] fix(config): replace deploy-web CSP nonce/strict-dynamic with host allowlist The report-only CSP added in #3473 used a per-request nonce plus 'strict-dynamic'. That is incompatible with deploy-web's statically optimized Pages Router routes: their HTML is generated at build time with no nonce, and 'strict-dynamic' makes browsers ignore 'self', so every first-party script chunk (framework-*.js) was reported. This flooded Sentry with 14k events / 650 users under DEPLOY-WEB-2JT and would white-screen every static page if CSP_MODE were set to enforce. Switch script-src to a host allowlist ('self' 'unsafe-inline' + vendor hosts) that needs no nonce and behaves identically on static and dynamic pages, so the policy is safe to enforce. Remove the now-dead nonce plumbing: generateNonce, the x-nonce request header, and getCspNonce/nonce application in domUtils. 'unsafe-inline' is an intentional, reviewed tradeoff: the app's GTM loader injects an inline bootstrap client-side, and the static Pages Router architecture cannot attach a per-request nonce. The host allowlist plus object-src 'none', base-uri 'self', frame-ancestors 'none' and form-action 'self' are retained. Keep CSP_MODE=report-only for now; watch Sentry for any GTM-loaded origins now that 'strict-dynamic' is gone and add them to the allowlist before flipping to enforce. Fixes DEPLOY-WEB-2JT --- apps/deploy-web/src/lib/csp/csp.spec.ts | 14 +++++++++++++- apps/deploy-web/src/lib/csp/csp.ts | 19 ++++--------------- apps/deploy-web/src/middleware.ts | 11 +++-------- apps/deploy-web/src/utils/domUtils.ts | 14 -------------- 4 files changed, 20 insertions(+), 38 deletions(-) diff --git a/apps/deploy-web/src/lib/csp/csp.spec.ts b/apps/deploy-web/src/lib/csp/csp.spec.ts index f72ff85721..e8e293a7bd 100644 --- a/apps/deploy-web/src/lib/csp/csp.spec.ts +++ b/apps/deploy-web/src/lib/csp/csp.spec.ts @@ -50,6 +50,17 @@ describe("csp", () => { }); describe("buildContentSecurityPolicy", () => { + it("allows first-party and vendor scripts via a host allowlist without a nonce or strict-dynamic", () => { + const { scriptSrc } = setup({}); + + expect(scriptSrc).toContain("'self'"); + expect(scriptSrc).toContain("'unsafe-inline'"); + expect(scriptSrc).toContain("https://www.googletagmanager.com"); + expect(scriptSrc).toContain("https://js.stripe.com"); + expect(scriptSrc).not.toContain("'strict-dynamic'"); + expect(scriptSrc).not.toContain("'nonce-"); + }); + it("includes origins derived from the provided env values", () => { const { connectSrc } = setup({ amplitudeProxyUrl: "https://console-proxy.akash.network/collect", @@ -156,10 +167,11 @@ describe("csp", () => { }); function setup(input: ContentSecurityPolicyInput) { - const policy = buildContentSecurityPolicy("test-nonce", input); + const policy = buildContentSecurityPolicy(input); const directives = Object.fromEntries(policy.split("; ").map(directive => [directive.split(" ")[0], directive])); return { policy, + scriptSrc: directives["script-src"], connectSrc: directives["connect-src"], imgSrc: directives["img-src"], reportUri: directives["report-uri"], diff --git a/apps/deploy-web/src/lib/csp/csp.ts b/apps/deploy-web/src/lib/csp/csp.ts index 4550554694..f0be07a672 100644 --- a/apps/deploy-web/src/lib/csp/csp.ts +++ b/apps/deploy-web/src/lib/csp/csp.ts @@ -3,8 +3,6 @@ const CSP_HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"; const CSP_REPORT_ENDPOINT_NAME = "csp-endpoint"; const CSP_REPORT_MAX_AGE_SECONDS = 10886400; -const NONCE_BYTE_LENGTH = 16; - const isDevelopment = process.env.NODE_ENV !== "production"; /** @@ -81,22 +79,13 @@ export function toSentrySecurityReportUri(dsn?: string): string | undefined { } } -/** - * Uses Web Crypto (available on the Edge/standard middleware runtime) since Node's - * `crypto` module is not guaranteed to be available where the middleware executes. - */ -export function generateNonce() { - const bytes = new Uint8Array(NONCE_BYTE_LENGTH); - crypto.getRandomValues(bytes); - return btoa(String.fromCharCode(...bytes)); -} - -export function buildContentSecurityPolicy(nonce: string, input: ContentSecurityPolicyInput) { +export function buildContentSecurityPolicy(input: ContentSecurityPolicyInput) { const scriptSrc = [ "'self'", - `'nonce-${nonce}'`, - "'strict-dynamic'", + "'unsafe-inline'", "https://www.googletagmanager.com", + "https://www.google-analytics.com", + "https://*.google-analytics.com", "https://pxl.growth-channel.net", "https://challenges.cloudflare.com", "https://js.stripe.com" diff --git a/apps/deploy-web/src/middleware.ts b/apps/deploy-web/src/middleware.ts index 381d90fb3e..5e2b81ed3a 100644 --- a/apps/deploy-web/src/middleware.ts +++ b/apps/deploy-web/src/middleware.ts @@ -3,7 +3,7 @@ import { netConfig } from "@akashnetwork/net"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { buildContentSecurityPolicy, generateNonce, getContentSecurityPolicyHeaderName, getContentSecurityPolicyReportHeaders } from "./lib/csp/csp"; +import { buildContentSecurityPolicy, getContentSecurityPolicyHeaderName, getContentSecurityPolicyReportHeaders } from "./lib/csp/csp"; const { MAINTENANCE_MODE } = process.env; const logger = new LoggerService({ name: "middleware" }); @@ -11,7 +11,6 @@ const logger = new LoggerService({ name: "middleware" }); const networkRpcAndApiUrls = netConfig.getSupportedNetworks().flatMap(network => [netConfig.getBaseRpcUrl(network), netConfig.getBaseAPIUrl(network)]); export function middleware(request: NextRequest) { - const nonce = generateNonce(); const contentSecurityPolicyInput = { mainnetApiUrl: process.env.NEXT_PUBLIC_BASE_API_MAINNET_URL, testnetApiUrl: process.env.NEXT_PUBLIC_BASE_API_TESTNET_URL, @@ -23,7 +22,7 @@ export function middleware(request: NextRequest) { templatesUrl: process.env.NEXT_PUBLIC_BASE_TEMPLATES_URL, networkRpcAndApiUrls }; - const contentSecurityPolicy = buildContentSecurityPolicy(nonce, contentSecurityPolicyInput); + const contentSecurityPolicy = buildContentSecurityPolicy(contentSecurityPolicyInput); const contentSecurityPolicyHeaderName = getContentSecurityPolicyHeaderName(); const contentSecurityPolicyReportHeaders = getContentSecurityPolicyReportHeaders(contentSecurityPolicyInput); @@ -45,11 +44,7 @@ export function middleware(request: NextRequest) { return redirectResponse; } - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-nonce", nonce); - requestHeaders.set(contentSecurityPolicyHeaderName, contentSecurityPolicy); - - const res = NextResponse.next({ request: { headers: requestHeaders } }); + const res = NextResponse.next(); setContentSecurityPolicyHeaders(res, contentSecurityPolicyHeaderName, contentSecurityPolicy, contentSecurityPolicyReportHeaders); const cookieName = "unleash-session-id"; diff --git a/apps/deploy-web/src/utils/domUtils.ts b/apps/deploy-web/src/utils/domUtils.ts index 3b8efca73d..bf539619f8 100644 --- a/apps/deploy-web/src/utils/domUtils.ts +++ b/apps/deploy-web/src/utils/domUtils.ts @@ -21,23 +21,9 @@ function scriptExists(id: string): boolean { return !!document.getElementById(id); } -/** - * Reads the active CSP nonce from an already-nonced script via the `.nonce` IDL property, - * since browsers clear the `nonce` content attribute after parsing. - */ -export function getCspNonce(): string | undefined { - return document.querySelector("script[nonce]")?.nonce || undefined; -} - function createScriptElement(options: ScriptOptions): HTMLScriptElement { const script = document.createElement("script"); Object.assign(script, options); - - const nonce = getCspNonce(); - if (nonce) { - script.nonce = nonce; - } - return script; } From 8ee729aac380f492d31376408ea9364894d443a4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:59:36 +0100 Subject: [PATCH 2/6] test(config): cover deploy-web middleware CSP headers middleware.ts had no test, so the CSP-header lines changed in this PR showed 0% patch coverage and failed codecov/patch/deploy-web (which in turn failed validate/result and the security-scan gate). Add middleware.spec.ts asserting the middleware attaches the CSP header (report-only by default, enforcing under CSP_MODE=enforce) and no longer emits an x-nonce header. --- apps/deploy-web/src/middleware.spec.ts | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/deploy-web/src/middleware.spec.ts diff --git a/apps/deploy-web/src/middleware.spec.ts b/apps/deploy-web/src/middleware.spec.ts new file mode 100644 index 0000000000..1ae1f4bff8 --- /dev/null +++ b/apps/deploy-web/src/middleware.spec.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { middleware } from "@src/middleware"; + +describe("middleware", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("sets a report-only CSP header with a host-allowlist script-src by default", () => { + const { response } = setup({ path: "/deployments" }); + + expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("script-src 'self'"); + expect(response.headers.get("Content-Security-Policy")).toBeNull(); + }); + + it("does not attach an x-nonce header", () => { + const { response } = setup({ path: "/" }); + + expect(response.headers.get("x-nonce")).toBeNull(); + }); + + it("emits the enforcing CSP header when CSP_MODE is enforce", () => { + vi.stubEnv("CSP_MODE", "enforce"); + + const { response } = setup({ path: "/" }); + + expect(response.headers.get("Content-Security-Policy")).toContain("script-src 'self'"); + expect(response.headers.get("Content-Security-Policy-Report-Only")).toBeNull(); + }); + + function setup(input: { path: string }) { + const request = new NextRequest(new URL(`http://localhost${input.path}`)); + const response = middleware(request); + return { request, response }; + } +}); From 767a0bb9851d1144a515147a9a3dbb9da72881d6 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:37:44 +0100 Subject: [PATCH 3/6] test(config): assert the full deploy-web CSP script-src allowlist Strengthen the CSP contract tests so a regression is caught: assert every approved script-src host (GTM, GA, growth-channel, cloudflare, stripe) and that 'strict-dynamic'/nonce sources stay absent. The middleware test now rejects those directives too, instead of a loose 'self' substring check. --- apps/deploy-web/src/lib/csp/csp.spec.ts | 4 ++++ apps/deploy-web/src/middleware.spec.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/deploy-web/src/lib/csp/csp.spec.ts b/apps/deploy-web/src/lib/csp/csp.spec.ts index e8e293a7bd..e15cd3da17 100644 --- a/apps/deploy-web/src/lib/csp/csp.spec.ts +++ b/apps/deploy-web/src/lib/csp/csp.spec.ts @@ -56,6 +56,10 @@ describe("csp", () => { expect(scriptSrc).toContain("'self'"); expect(scriptSrc).toContain("'unsafe-inline'"); expect(scriptSrc).toContain("https://www.googletagmanager.com"); + expect(scriptSrc).toContain("https://www.google-analytics.com"); + expect(scriptSrc).toContain("https://*.google-analytics.com"); + expect(scriptSrc).toContain("https://pxl.growth-channel.net"); + expect(scriptSrc).toContain("https://challenges.cloudflare.com"); expect(scriptSrc).toContain("https://js.stripe.com"); expect(scriptSrc).not.toContain("'strict-dynamic'"); expect(scriptSrc).not.toContain("'nonce-"); diff --git a/apps/deploy-web/src/middleware.spec.ts b/apps/deploy-web/src/middleware.spec.ts index 1ae1f4bff8..0f78e7753b 100644 --- a/apps/deploy-web/src/middleware.spec.ts +++ b/apps/deploy-web/src/middleware.spec.ts @@ -11,7 +11,10 @@ describe("middleware", () => { it("sets a report-only CSP header with a host-allowlist script-src by default", () => { const { response } = setup({ path: "/deployments" }); - expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("script-src 'self'"); + const csp = response.headers.get("Content-Security-Policy-Report-Only"); + expect(csp).toContain("script-src 'self' 'unsafe-inline'"); + expect(csp).not.toContain("'strict-dynamic'"); + expect(csp).not.toContain("'nonce-"); expect(response.headers.get("Content-Security-Policy")).toBeNull(); }); From 628d4362705f84df340becbf777e8e12d7042caf Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:05:37 +0100 Subject: [PATCH 4/6] fix(analytics): load GTM without an inline bootstrap script --- .../layout/TrackingScripts.spec.tsx | 84 +++++++++++++++++++ .../src/components/layout/TrackingScripts.tsx | 61 ++++++++------ apps/deploy-web/src/utils/domUtils.ts | 1 - 3 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx diff --git a/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx new file mode 100644 index 0000000000..0e8359333d --- /dev/null +++ b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { DEPENDENCIES } from "./TrackingScripts"; +import { TrackingScripts } from "./TrackingScripts"; + +import { render } from "@testing-library/react"; + +describe("TrackingScripts", () => { + it("loads gtm.js as an external script without inline content and seeds the dataLayer", () => { + setup({}); + + const gtmScript = document.getElementById("gtm") as HTMLScriptElement; + expect(gtmScript).toBeInTheDocument(); + expect(gtmScript.src).toBe("https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"); + expect(gtmScript.async).toBe(true); + expect(gtmScript.textContent).toBe(""); + expect(window.dataLayer).toEqual([{ "gtm.start": expect.any(Number), event: "gtm.js" }]); + }); + + it("appends the GTM noscript iframe fallback", () => { + setup({}); + + const gtmIframe = document.querySelector("noscript iframe"); + expect(gtmIframe).toHaveAttribute("src", "https://www.googletagmanager.com/ns.html?id=GTM-TEST123"); + }); + + it("does not duplicate the GTM bootstrap when mounted twice", () => { + const { renderComponent } = setup({}); + + renderComponent(); + + expect(document.querySelectorAll("#gtm")).toHaveLength(1); + expect(window.dataLayer).toHaveLength(1); + }); + + it("appends growth-channel pixel scripts when growth-channel tracking is enabled", () => { + setup({ growthChannelEnabled: true }); + + expect(document.getElementById("growth-channel-script-retargeting")).toBeInTheDocument(); + expect(document.getElementById("growth-channel-script-console")).toBeInTheDocument(); + }); + + it("does not append growth-channel pixel scripts when growth-channel tracking is disabled", () => { + setup({}); + + expect(document.getElementById("growth-channel-script-retargeting")).toBeNull(); + expect(document.getElementById("growth-channel-script-console")).toBeNull(); + }); + + it("adds no tracking scripts outside production", () => { + setup({ nodeEnv: "development" }); + + expect(document.getElementById("gtm")).toBeNull(); + expect(window.dataLayer).toBeUndefined(); + }); + + it("adds no tracking scripts when tracking is disabled", () => { + setup({ trackingEnabled: false }); + + expect(document.getElementById("gtm")).toBeNull(); + expect(window.dataLayer).toBeUndefined(); + }); + + function setup(input: { nodeEnv?: "development" | "production" | "test"; trackingEnabled?: boolean; growthChannelEnabled?: boolean }) { + document.querySelectorAll("#gtm, #growth-channel-script-retargeting, #growth-channel-script-console, noscript").forEach(element => element.remove()); + delete window.dataLayer; + + const useServices: typeof DEPENDENCIES.useServices = () => + mock>({ + publicConfig: { + NEXT_PUBLIC_NODE_ENV: input.nodeEnv ?? "production", + NEXT_PUBLIC_TRACKING_ENABLED: input.trackingEnabled ?? true, + NEXT_PUBLIC_GROWTH_CHANNEL_TRACKING_ENABLED: input.growthChannelEnabled ?? false, + NEXT_PUBLIC_GTM_ID: "GTM-TEST123" + } + }); + + const renderComponent = () => render(); + renderComponent(); + + return { renderComponent }; + } +}); diff --git a/apps/deploy-web/src/components/layout/TrackingScripts.tsx b/apps/deploy-web/src/components/layout/TrackingScripts.tsx index 1dd5218fb1..4591e2aeda 100644 --- a/apps/deploy-web/src/components/layout/TrackingScripts.tsx +++ b/apps/deploy-web/src/components/layout/TrackingScripts.tsx @@ -4,7 +4,41 @@ import { useEffect } from "react"; import { useServices } from "@src/context/ServicesProvider"; import { addScriptToBody } from "@src/utils/domUtils"; -export const TrackingScripts = () => { +export const DEPENDENCIES = { useServices }; + +const GTM_SCRIPT_ID = "gtm"; + +/** + * Bootstraps GTM without the stock inline snippet — deploy-web's CSP script-src has no 'unsafe-inline', + * so the dataLayer is seeded directly and gtm.js loads as an allowlisted external script. + */ +function loadGoogleTagManager(gtmId?: string) { + if (document.getElementById(GTM_SCRIPT_ID)) return; + + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ "gtm.start": new Date().getTime(), event: "gtm.js" }); + addScriptToBody({ + id: GTM_SCRIPT_ID, + src: `https://www.googletagmanager.com/gtm.js?id=${gtmId}`, + async: true + }); + appendGtmNoscriptFallback(gtmId); +} + +function appendGtmNoscriptFallback(gtmId?: string) { + const gtmNoscript = document.createElement("noscript"); + const gtmIframe = document.createElement("iframe"); + gtmIframe.src = `https://www.googletagmanager.com/ns.html?id=${gtmId}`; + gtmIframe.height = "0"; + gtmIframe.width = "0"; + gtmIframe.style.display = "none"; + gtmIframe.style.visibility = "hidden"; + gtmNoscript.appendChild(gtmIframe); + document.body.appendChild(gtmNoscript); +} + +export const TrackingScripts = ({ dependencies = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES }) => { + const { useServices } = dependencies; const { publicConfig } = useServices(); const isProduction = publicConfig.NEXT_PUBLIC_NODE_ENV === "production"; @@ -13,33 +47,10 @@ export const TrackingScripts = () => { const shouldShowGrowthChannel = publicConfig.NEXT_PUBLIC_GROWTH_CHANNEL_TRACKING_ENABLED; if (isProduction && shouldShowTracking) { - // Google Tag Manager - addScriptToBody({ - id: "gtm", - type: "text/javascript", - innerHTML: ` - (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': - new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], - j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); - })(window,document,'script','dataLayer','${publicConfig.NEXT_PUBLIC_GTM_ID}'); - ` - }); - - // GTM noscript fallback - const gtmNoscript = document.createElement("noscript"); - const gtmIframe = document.createElement("iframe"); - gtmIframe.src = `https://www.googletagmanager.com/ns.html?id=${publicConfig.NEXT_PUBLIC_GTM_ID}`; - gtmIframe.height = "0"; - gtmIframe.width = "0"; - gtmIframe.style.display = "none"; - gtmIframe.style.visibility = "hidden"; - gtmNoscript.appendChild(gtmIframe); - document.body.appendChild(gtmNoscript); + loadGoogleTagManager(publicConfig.NEXT_PUBLIC_GTM_ID); } if (isProduction && shouldShowTracking && shouldShowGrowthChannel) { - // Growth Channel tracking addScriptToBody({ src: "https://pxl.growth-channel.net/s/8d425860-cf3c-49cf-a459-069a7dc7b1f8", async: true, diff --git a/apps/deploy-web/src/utils/domUtils.ts b/apps/deploy-web/src/utils/domUtils.ts index bf539619f8..a4f4540458 100644 --- a/apps/deploy-web/src/utils/domUtils.ts +++ b/apps/deploy-web/src/utils/domUtils.ts @@ -14,7 +14,6 @@ interface ScriptOptions { noModule?: boolean; referrerPolicy?: ReferrerPolicy; id?: string; - innerHTML?: string; } function scriptExists(id: string): boolean { From 04729378f446a56cdd0999c57c4c284d1f711072 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:05:54 +0100 Subject: [PATCH 5/6] fix(config): drop unsafe-inline from deploy-web CSP script-src --- .../layout/AppThemeProvider.spec.tsx | 24 +++++++++++++++++++ .../components/layout/AppThemeProvider.tsx | 15 ++++++++++++ apps/deploy-web/src/lib/csp/csp.spec.ts | 7 +++--- apps/deploy-web/src/lib/csp/csp.ts | 11 +++++++-- apps/deploy-web/src/middleware.spec.ts | 6 ++++- apps/deploy-web/src/pages/_app.tsx | 6 ++--- 6 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx create mode 100644 apps/deploy-web/src/components/layout/AppThemeProvider.tsx diff --git a/apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx b/apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx new file mode 100644 index 0000000000..e4f2468d75 --- /dev/null +++ b/apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx @@ -0,0 +1,24 @@ +import crypto from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { THEME_SCRIPT_HASH } from "@src/lib/csp/csp"; +import { AppThemeProvider } from "./AppThemeProvider"; + +import { render } from "@testing-library/react"; + +describe("AppThemeProvider", () => { + it("renders an inline theme script matching the CSP theme script hash", () => { + const { renderedScriptHash } = setup(); + + expect(THEME_SCRIPT_HASH, `the next-themes inline script changed - update THEME_SCRIPT_HASH in lib/csp/csp.ts to ${renderedScriptHash}`).toBe( + renderedScriptHash + ); + }); + + function setup() { + const { container } = render(content); + const scriptText = container.querySelector("script")?.textContent ?? ""; + const renderedScriptHash = `'sha256-${crypto.createHash("sha256").update(scriptText, "utf8").digest("base64")}'`; + return { renderedScriptHash }; + } +}); diff --git a/apps/deploy-web/src/components/layout/AppThemeProvider.tsx b/apps/deploy-web/src/components/layout/AppThemeProvider.tsx new file mode 100644 index 0000000000..cd7a0a17d8 --- /dev/null +++ b/apps/deploy-web/src/components/layout/AppThemeProvider.tsx @@ -0,0 +1,15 @@ +"use client"; +import type { ReactNode } from "react"; +import { ThemeProvider } from "next-themes"; + +/** + * next-themes injects an inline anti-FOUC script derived from these exact props, and deploy-web's CSP + * allows it by sha256 (THEME_SCRIPT_HASH in lib/csp/csp.ts) since static Pages Router HTML cannot carry + * a nonce. Keeping the provider here lets AppThemeProvider.spec.tsx render the production configuration + * and fail when a next-themes upgrade or prop change alters the script without updating the hash. + */ +export const AppThemeProvider = ({ children }: { children: ReactNode }) => ( + + {children} + +); diff --git a/apps/deploy-web/src/lib/csp/csp.spec.ts b/apps/deploy-web/src/lib/csp/csp.spec.ts index e15cd3da17..010ea6bdb8 100644 --- a/apps/deploy-web/src/lib/csp/csp.spec.ts +++ b/apps/deploy-web/src/lib/csp/csp.spec.ts @@ -5,6 +5,7 @@ import { type ContentSecurityPolicyInput, getContentSecurityPolicyHeaderName, getContentSecurityPolicyReportHeaders, + THEME_SCRIPT_HASH, toOrigin, toSentrySecurityReportUri } from "./csp"; @@ -50,19 +51,19 @@ describe("csp", () => { }); describe("buildContentSecurityPolicy", () => { - it("allows first-party and vendor scripts via a host allowlist without a nonce or strict-dynamic", () => { + it("allows first-party, vendor, and hashed theme scripts without a nonce, strict-dynamic, or unsafe-inline", () => { const { scriptSrc } = setup({}); expect(scriptSrc).toContain("'self'"); - expect(scriptSrc).toContain("'unsafe-inline'"); + expect(scriptSrc).toContain(THEME_SCRIPT_HASH); expect(scriptSrc).toContain("https://www.googletagmanager.com"); - expect(scriptSrc).toContain("https://www.google-analytics.com"); expect(scriptSrc).toContain("https://*.google-analytics.com"); expect(scriptSrc).toContain("https://pxl.growth-channel.net"); expect(scriptSrc).toContain("https://challenges.cloudflare.com"); expect(scriptSrc).toContain("https://js.stripe.com"); expect(scriptSrc).not.toContain("'strict-dynamic'"); expect(scriptSrc).not.toContain("'nonce-"); + expect(scriptSrc).not.toContain("'unsafe-inline'"); }); it("includes origins derived from the provided env values", () => { diff --git a/apps/deploy-web/src/lib/csp/csp.ts b/apps/deploy-web/src/lib/csp/csp.ts index f0be07a672..eba5cf3b6e 100644 --- a/apps/deploy-web/src/lib/csp/csp.ts +++ b/apps/deploy-web/src/lib/csp/csp.ts @@ -5,6 +5,14 @@ const CSP_REPORT_MAX_AGE_SECONDS = 10886400; const isDevelopment = process.env.NODE_ENV !== "production"; +/** + * sha256 of the inline anti-FOUC script next-themes renders for AppThemeProvider's exact props, allowing + * it without 'unsafe-inline' (static Pages Router HTML cannot carry a nonce). AppThemeProvider.spec.tsx + * recomputes the hash from the rendered provider and reports the new value when a next-themes upgrade or + * prop change alters the script. + */ +export const THEME_SCRIPT_HASH = "'sha256-eMuh8xiwcX72rRYNAGENurQBAcH7kLlAUQcoOri3BIo='"; + /** * Third-party endpoints the app connects to directly (Stripe, Cloudflare, Google, Growth Channel, Amplitude); these never vary by environment. * Amplitude core events are proxied via NEXT_PUBLIC_AMPLITUDE_PROXY_URL, but Session Replay (config + ingest) and the no-proxy fallback hit @@ -82,9 +90,8 @@ export function toSentrySecurityReportUri(dsn?: string): string | undefined { export function buildContentSecurityPolicy(input: ContentSecurityPolicyInput) { const scriptSrc = [ "'self'", - "'unsafe-inline'", + THEME_SCRIPT_HASH, "https://www.googletagmanager.com", - "https://www.google-analytics.com", "https://*.google-analytics.com", "https://pxl.growth-channel.net", "https://challenges.cloudflare.com", diff --git a/apps/deploy-web/src/middleware.spec.ts b/apps/deploy-web/src/middleware.spec.ts index 0f78e7753b..7d08e5a664 100644 --- a/apps/deploy-web/src/middleware.spec.ts +++ b/apps/deploy-web/src/middleware.spec.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { THEME_SCRIPT_HASH } from "@src/lib/csp/csp"; import { middleware } from "@src/middleware"; describe("middleware", () => { @@ -12,7 +13,10 @@ describe("middleware", () => { const { response } = setup({ path: "/deployments" }); const csp = response.headers.get("Content-Security-Policy-Report-Only"); - expect(csp).toContain("script-src 'self' 'unsafe-inline'"); + const scriptSrc = csp?.split("; ").find(directive => directive.startsWith("script-src ")); + expect(scriptSrc).toContain("'self'"); + expect(scriptSrc).toContain(THEME_SCRIPT_HASH); + expect(scriptSrc).not.toContain("'unsafe-inline'"); expect(csp).not.toContain("'strict-dynamic'"); expect(csp).not.toContain("'nonce-"); expect(response.headers.get("Content-Security-Policy")).toBeNull(); diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index e62c1832e3..771a781f5a 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -15,10 +15,10 @@ import dynamic from "next/dynamic"; import Router from "next/router"; import { NavigationGuardProvider } from "next-navigation-guard"; import type { NextSeoProps } from "next-seo/lib/types"; -import { ThemeProvider } from "next-themes"; import NProgress from "nprogress"; import { RequireAuth } from "@src/components/auth/RequireAuth/RequireAuth"; +import { AppThemeProvider } from "@src/components/layout/AppThemeProvider"; import { CustomIntlProvider } from "@src/components/layout/CustomIntlProvider"; import { PageHead } from "@src/components/layout/PageHead"; import { RequireOnboarding } from "@src/components/onboarding/RequireOnboarding/RequireOnboarding"; @@ -89,7 +89,7 @@ function AppRoot(props: Props & { children: React.ReactNode }) { - + @@ -104,7 +104,7 @@ function AppRoot(props: Props & { children: React.ReactNode }) { - + From cbfe59c26b5dd79f88f46ac4aec1588d2e14f64c Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:29:47 +0100 Subject: [PATCH 6/6] fix(analytics): skip GTM bootstrap when no GTM container id is configured --- .../src/components/layout/TrackingScripts.spec.tsx | 12 ++++++++++-- .../src/components/layout/TrackingScripts.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx index 0e8359333d..d358ca778c 100644 --- a/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx +++ b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx @@ -62,7 +62,15 @@ describe("TrackingScripts", () => { expect(window.dataLayer).toBeUndefined(); }); - function setup(input: { nodeEnv?: "development" | "production" | "test"; trackingEnabled?: boolean; growthChannelEnabled?: boolean }) { + it("does not load GTM when no GTM container id is configured", () => { + setup({ gtmId: undefined }); + + expect(document.getElementById("gtm")).toBeNull(); + expect(document.querySelector("noscript")).toBeNull(); + expect(window.dataLayer).toBeUndefined(); + }); + + function setup(input: { nodeEnv?: "development" | "production" | "test"; trackingEnabled?: boolean; growthChannelEnabled?: boolean; gtmId?: string }) { document.querySelectorAll("#gtm, #growth-channel-script-retargeting, #growth-channel-script-console, noscript").forEach(element => element.remove()); delete window.dataLayer; @@ -72,7 +80,7 @@ describe("TrackingScripts", () => { NEXT_PUBLIC_NODE_ENV: input.nodeEnv ?? "production", NEXT_PUBLIC_TRACKING_ENABLED: input.trackingEnabled ?? true, NEXT_PUBLIC_GROWTH_CHANNEL_TRACKING_ENABLED: input.growthChannelEnabled ?? false, - NEXT_PUBLIC_GTM_ID: "GTM-TEST123" + NEXT_PUBLIC_GTM_ID: "gtmId" in input ? input.gtmId : "GTM-TEST123" } }); diff --git a/apps/deploy-web/src/components/layout/TrackingScripts.tsx b/apps/deploy-web/src/components/layout/TrackingScripts.tsx index 4591e2aeda..b6276315ce 100644 --- a/apps/deploy-web/src/components/layout/TrackingScripts.tsx +++ b/apps/deploy-web/src/components/layout/TrackingScripts.tsx @@ -13,7 +13,7 @@ const GTM_SCRIPT_ID = "gtm"; * so the dataLayer is seeded directly and gtm.js loads as an allowlisted external script. */ function loadGoogleTagManager(gtmId?: string) { - if (document.getElementById(GTM_SCRIPT_ID)) return; + if (!gtmId || document.getElementById(GTM_SCRIPT_ID)) return; window.dataLayer = window.dataLayer || []; window.dataLayer.push({ "gtm.start": new Date().getTime(), event: "gtm.js" });