From 859f0d06cfe7bdf2f1c5137dec14b6fb30146ec4 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 7 Sep 2026 21:24:54 +0100 Subject: [PATCH] fix(csp): allow the YouTube embed host on every variant, not just /documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide videos were blocked with Chrome's "This content is blocked" screen whenever /documents was reached by clicking from another page. A CSP belongs to the document, and a client-side navigation keeps the landing page's policy, whose frame-src had no YouTube host — only a direct load of the guide URL worked. The cookie-less player host is now on every variant, with a regression test per variant. --- nextjs_space/lib/security/csp.ts | 13 +++++++++++-- nextjs_space/tests/unit/csp.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/nextjs_space/lib/security/csp.ts b/nextjs_space/lib/security/csp.ts index 12f145cf..50453833 100644 --- a/nextjs_space/lib/security/csp.ts +++ b/nextjs_space/lib/security/csp.ts @@ -28,6 +28,9 @@ const GA4_IMG_HOSTS = [ "https://www.googletagmanager.com", ] as const; +/** YouTube's privacy-enhanced embed host — the only third-party frame origin. */ +export const YOUTUBE_FRAME_HOST = "https://www.youtube-nocookie.com"; + /** * Fresh per-request nonce: 16 random bytes (128-bit) base64-encoded. Never * reused across requests; generated once per request in middleware. @@ -94,8 +97,14 @@ export function buildCsp({ // and every hit is blocked, which looks exactly like "analytics is broken". `connect-src 'self' https://*.clerk.accounts.dev https://api.clerk.com https://*.drgreennft.com https://*.amazonaws.com wss://*.clerk.accounts.dev${store ? ` ${GA4_CONNECT_HOSTS.join(" ")}` : ""}`, // The docs pages (/documents) embed guide videos via YouTube's - // privacy-enhanced host — allowed on that variant only, nowhere else. - `frame-src 'self' https://challenges.cloudflare.com https://*.clerk.accounts.dev${variant === "docs" ? " https://www.youtube-nocookie.com" : ""}`, + // privacy-enhanced host. This used to be allowed on the docs variant only, + // which broke in practice: a CSP belongs to the DOCUMENT, and a client-side + // navigation from the landing page (or the admin) into /documents keeps the + // landing page's policy, so every embed rendered Chrome's "This content is + // blocked" screen unless the guide URL was loaded directly. The frame host + // is therefore allowed on every variant; it is the cookie-less player host + // and nothing else can be framed from it. + `frame-src 'self' https://challenges.cloudflare.com https://*.clerk.accounts.dev ${YOUTUBE_FRAME_HOST}`, `frame-ancestors ${frameAncestors}`, "object-src 'none'", "base-uri 'self'", diff --git a/nextjs_space/tests/unit/csp.test.ts b/nextjs_space/tests/unit/csp.test.ts index 974091fc..d12fc645 100644 --- a/nextjs_space/tests/unit/csp.test.ts +++ b/nextjs_space/tests/unit/csp.test.ts @@ -178,3 +178,31 @@ describe("applyCsp", () => { expect(res.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'"); }); }); + +describe("frame-src — YouTube guide embeds", () => { + // A CSP belongs to the document, not the route: a client-side navigation from + // the landing page into /documents keeps the landing page's policy. Gating + // the YouTube frame host to the docs variant therefore blocked every guide + // video reached by clicking (Chrome: "This content is blocked"), while a + // direct load of the same URL worked. The host must be on every variant. + it.each(["base", "admin", "store", "docs"])( + "allows the privacy-enhanced YouTube host on the %s variant", + (variant) => { + const frameSrc = directive(buildCsp({ nonce: NONCE, variant }), "frame-src"); + expect(frameSrc).toContain("https://www.youtube-nocookie.com"); + expect(frameSrc).toContain("'self'"); + }, + ); + + it("does not widen frame-src beyond the known hosts", () => { + const frameSrc = directive(buildCsp({ nonce: NONCE, variant: "base" }), "frame-src"); + expect(frameSrc.split(" ").slice(1).sort()).toEqual( + [ + "'self'", + "https://*.clerk.accounts.dev", + "https://challenges.cloudflare.com", + "https://www.youtube-nocookie.com", + ].sort(), + ); + }); +});