Skip to content
Open
24 changes: 24 additions & 0 deletions apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<AppThemeProvider>content</AppThemeProvider>);
const scriptText = container.querySelector("script")?.textContent ?? "";
const renderedScriptHash = `'sha256-${crypto.createHash("sha256").update(scriptText, "utf8").digest("base64")}'`;
return { renderedScriptHash };
}
});
15 changes: 15 additions & 0 deletions apps/deploy-web/src/components/layout/AppThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ThemeProvider attribute="class" defaultTheme="system" storageKey="theme" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
);
92 changes: 92 additions & 0 deletions apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof DEPENDENCIES.useServices>>({
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(<TrackingScripts dependencies={{ useServices }} />);
renderComponent();

return { renderComponent };
}
});
61 changes: 36 additions & 25 deletions apps/deploy-web/src/components/layout/TrackingScripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const TrackingScripts = ({ dependencies = DEPENDENCIES }: { dependencies?: typeof DEPENDENCIES }) => {
const { useServices } = dependencies;
const { publicConfig } = useServices();
const isProduction = publicConfig.NEXT_PUBLIC_NODE_ENV === "production";

Expand All @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion apps/deploy-web/src/lib/csp/csp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ContentSecurityPolicyInput,
getContentSecurityPolicyHeaderName,
getContentSecurityPolicyReportHeaders,
THEME_SCRIPT_HASH,
toOrigin,
toSentrySecurityReportUri
} from "./csp";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
26 changes: 11 additions & 15 deletions apps/deploy-web/src/lib/csp/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"https://pxl.growth-channel.net",
"https://challenges.cloudflare.com",
"https://js.stripe.com"
Expand Down
45 changes: 45 additions & 0 deletions apps/deploy-web/src/middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 };
}
});
11 changes: 3 additions & 8 deletions apps/deploy-web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ 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" });

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,
Expand All @@ -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);

Expand All @@ -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";
Expand Down
Loading