-
Notifications
You must be signed in to change notification settings - Fork 89
fix(config): replace deploy-web CSP nonce/strict-dynamic with host allowlist #3523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
baktun14
wants to merge
8
commits into
main
Choose a base branch
from
fix/deploy-web-csp-drop-nonce-strict-dynamic
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e855ea0
fix(config): replace deploy-web CSP nonce/strict-dynamic with host al…
baktun14 8ee729a
test(config): cover deploy-web middleware CSP headers
baktun14 767a0bb
test(config): assert the full deploy-web CSP script-src allowlist
baktun14 9c2323d
Merge remote-tracking branch 'origin/main' into fix/deploy-web-csp-dr…
baktun14 770d6a8
Merge remote-tracking branch 'origin/main' into fix/deploy-web-csp-dr…
baktun14 628d436
fix(analytics): load GTM without an inline bootstrap script
baktun14 0472937
fix(config): drop unsafe-inline from deploy-web CSP script-src
baktun14 cbfe59c
fix(analytics): skip GTM bootstrap when no GTM container id is config…
baktun14 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
24 changes: 24 additions & 0 deletions
24
apps/deploy-web/src/components/layout/AppThemeProvider.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
15
apps/deploy-web/src/components/layout/AppThemeProvider.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
92
apps/deploy-web/src/components/layout/TrackingScripts.spec.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
|
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 }; | ||
| } | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.