spomeni/next.config.ts
dimitar dd0dd9c2dc
Some checks are pending
CI / build (push) Waiting to run
fix(csp): handle base64-encoded Clerk publishable keys
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_<base64slug>\$
                    /-> decode b64 /-> <slug>.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_<slug>-<randomSuffix>
                    /-> <slug>.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.
2026-08-02 21:42:53 +02:00

117 lines
4.4 KiB
TypeScript

import type { NextConfig } from "next";
const s3Host = process.env.S3_ENDPOINT
? new URL(process.env.S3_ENDPOINT).hostname
: "";
// Derive the Clerk frontend API host from the publishable key so the
// CSP allowlist always matches the active environment. Clerk supports
// two publishable key formats:
//
// 1) "Encoded" form (older): pk_test_<base64slug>$
// The base64 portion decodes to "<slug>.clerk.accounts.dev" (test)
// or "<slug>.clerk.services" (live). Trailing '$' is a separator.
//
// 2) "Readable" form (newer): pk_test_<slug>-<randomSuffix>
// -> <slug>.clerk.accounts.dev (test) or <slug>.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();
return m[1].toLowerCase() === "test"
? `${slug}.clerk.accounts.dev`
: `${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 = [
"default-src 'self'",
// Clerk user avatars are served from img.clerk.com; S3 hosts are
// also allowed for memorial uploads. data:/blob: for in-app previews.
"img-src 'self' data: blob: https://img.clerk.com https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
// script-src: must include the Clerk FAPI host because Clerk JS is
// loaded from <fapiHost>/npm/@clerk/clerk-js@<v>/dist/clerk.browser.js
"script-src 'self' 'unsafe-inline' 'unsafe-eval'" +
(clerkFapiHost ? ` https://${clerkFapiHost}` : ""),
// connect-src: Clerk JS talks to <fapiHost> for all session calls.
"connect-src 'self' https://api.clerk.com" +
(clerkFapiHost ? ` https://${clerkFapiHost} wss://${clerkFapiHost}` : ""),
"frame-ancestors 'self'",
].join("; ");
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Content-Security-Policy", value: csp },
];
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
compress: true,
images: {
remotePatterns: [
{ protocol: "https", hostname: "img.clerk.com" },
...(s3Host ? [{ protocol: "https", hostname: s3Host }] : []),
] as NonNullable<NonNullable<NextConfig["images"]>["remotePatterns"]>,
},
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
];
},
};
export default nextConfig;