From dd0dd9c2dcd41b91d62d8f5cb9e13b4e4dcda404 Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 21:42:53 +0200 Subject: [PATCH] fix(csp): handle base64-encoded Clerk publishable keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation with the running container revealed the previous fix was correct on the deployed server but didn't help the user because NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in their .env has the placeholder 'pk_test_...' from .env.example, not the readable form like 'pk_test_useful-louse-74-O42m8W' I tested against. The actual key is in Clerk's older "encoded" format: pk_test_dXNlZnVsLWxvdXNlLTc0LmNsZXJrLmFjY291bnRzLmRldiQ The base64 portion decodes to the literal FAPI host 'useful-louse-74.clerk.accounts.dev' (with a trailing '$' separator), which is exactly the host shown in the error message. So script-src needs to allow exactly that host, and my previous regex only knew the readable form. clerkFrontendApiHost() now handles both formats: Form 1 (encoded): pk_test_\$ /-> decode b64 /-> .clerk.accounts.dev (or .clerk.services for ?) Note: the encoded payload always carries the literal hostname regardless of test/live; we accept either well-known TLD suffix on the decoded string. Form 2 (readable): pk_test_- /-> .clerk.accounts.dev Captured greedily (slug may contain digits and hyphens) — kept as a fallback. Defensive fall-throughs ensure a string that decodes to garbage (e.g. a readable-form key passed through the b64 regex) doesn't silently return null — it falls through to form 2. Verified against four cases: pk_test_dXNlZnVsLWxvdXNlLTc0... -> useful-louse-74.clerk.accounts.dev ✓ pk_test_useful-louse-74-O42m8W -> useful-louse-74.clerk.accounts.dev ✓ pk_live_dXNlZnVsLWxvdXNlLTc0... -> useful-louse-74.clerk.accounts.dev ✓ pk_test_invalid-garbage -> invalid.clerk.accounts.dev (form 2) The user must rebuild and redeploy for the new CSP header to take effect — the previously-served header is cached in the running container's standalone bundle and won't refresh until container restart with the new build. --- next.config.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/next.config.ts b/next.config.ts index ecc1933..dbc420a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,15 +5,43 @@ const s3Host = process.env.S3_ENDPOINT : ""; // Derive the Clerk frontend API host from the publishable key so the -// CSP allowlist always matches the active environment. -// Clerk key format is: -// pk_test_- -> .clerk.accounts.dev -// pk_live_- -> .clerk.services -// The slug itself may contain hyphens and digits, so the suffix is -// only the final dash-group. The regex greedily captures the slug. +// CSP allowlist always matches the active environment. Clerk supports +// two publishable key formats: +// +// 1) "Encoded" form (older): pk_test_$ +// The base64 portion decodes to ".clerk.accounts.dev" (test) +// or ".clerk.services" (live). Trailing '$' is a separator. +// +// 2) "Readable" form (newer): pk_test_- +// -> .clerk.accounts.dev (test) or .clerk.services. +// The slug may itself contain hyphens and digits, so only the +// final dash-group is captured as the suffix. function clerkFrontendApiHost(): string | null { const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; if (!key) return null; + + // Form 1: base64-encoded FAPI URL. Match everything between the + // 'pk_test_'/'pk_live_' prefix and an optional trailing '$'. + const enc = key.match(/^pk_(test|live)_([A-Za-z0-9+/=_-]+)\$?$/); + if (enc) { + const b64 = enc[2].replace(/-/g, "+").replace(/_/g, "/"); + if (/^[A-Za-z0-9+/=]+$/.test(b64)) { + try { + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const decoded = Buffer.from(padded, "base64").toString("utf8"); + // If the decoded string doesn't look like a Clerk FAPI host + // (e.g. it's garbage from decoding a non-base64 readable-form + // key), fall through to form 2 rather than returning null. + const fapi = decodeFapiHost(decoded); + if (fapi) return fapi; + } catch { + // fall through to form 2 + } + } + // fall through to form 2 if the slug is non-base64 (e.g. readable form) + } + + // Form 2: readable slug + random suffix. const m = key.match(/^pk_(test|live)_(.+?)-([a-z0-9]+)$/i); if (!m) return null; const slug = m[2].toLowerCase(); @@ -22,6 +50,20 @@ function clerkFrontendApiHost(): string | null { : `${slug}.clerk.services`; } +function decodeFapiHost(decoded: string): string | null { + // The decoded string is the FAPI host (e.g. + // 'useful-louse-74.clerk.accounts.dev$'). Note: the encoded base64 + // payload always carries the literal FAPI host regardless of test vs + // live mode — both `pk_test_...` and `pk_live_...` can decode to an + // '.accounts.dev' host when the deployment is on the test endpoint. + // We accept either well-known Clerk FAPI host pattern. + const host = decoded.trim().replace(/\$$/, "").trim().toLowerCase(); + if (host.endsWith(".clerk.accounts.dev") || host.endsWith(".clerk.services")) { + return host; + } + return null; +} + const clerkFapiHost = clerkFrontendApiHost(); const csp = [