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/components/layout/TrackingScripts.spec.tsx b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx
new file mode 100644
index 0000000000..d358ca778c
--- /dev/null
+++ b/apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx
@@ -0,0 +1,92 @@
+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();
+ });
+
+ 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;
+
+ 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: "gtmId" in input ? input.gtmId : "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..b6276315ce 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 (!gtmId || 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/lib/csp/csp.spec.ts b/apps/deploy-web/src/lib/csp/csp.spec.ts
index f72ff85721..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,6 +51,21 @@ describe("csp", () => {
});
describe("buildContentSecurityPolicy", () => {
+ 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(THEME_SCRIPT_HASH);
+ expect(scriptSrc).toContain("https://www.googletagmanager.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", () => {
const { connectSrc } = setup({
amplitudeProxyUrl: "https://console-proxy.akash.network/collect",
@@ -156,10 +172,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..eba5cf3b6e 100644
--- a/apps/deploy-web/src/lib/csp/csp.ts
+++ b/apps/deploy-web/src/lib/csp/csp.ts
@@ -3,10 +3,16 @@ 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";
+/**
+ * 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
@@ -81,22 +87,12 @@ 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'",
+ THEME_SCRIPT_HASH,
"https://www.googletagmanager.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.spec.ts b/apps/deploy-web/src/middleware.spec.ts
new file mode 100644
index 0000000000..7d08e5a664
--- /dev/null
+++ b/apps/deploy-web/src/middleware.spec.ts
@@ -0,0 +1,45 @@
+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", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("sets a report-only CSP header with a host-allowlist script-src by default", () => {
+ const { response } = setup({ path: "/deployments" });
+
+ const csp = response.headers.get("Content-Security-Policy-Report-Only");
+ 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();
+ });
+
+ 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 };
+ }
+});
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/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 }) {
-
+
diff --git a/apps/deploy-web/src/utils/domUtils.ts b/apps/deploy-web/src/utils/domUtils.ts
index 3b8efca73d..a4f4540458 100644
--- a/apps/deploy-web/src/utils/domUtils.ts
+++ b/apps/deploy-web/src/utils/domUtils.ts
@@ -14,30 +14,15 @@ interface ScriptOptions {
noModule?: boolean;
referrerPolicy?: ReferrerPolicy;
id?: string;
- innerHTML?: string;
}
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;
}