diff --git a/.changeset/poor-singers-camp.md b/.changeset/poor-singers-camp.md new file mode 100644 index 00000000000..768264ed1b2 --- /dev/null +++ b/.changeset/poor-singers-camp.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Added constants.Headers.ContentSecurityPolicy and constants.Headers.Nonce diff --git a/.changeset/vast-clubs-speak.md b/.changeset/vast-clubs-speak.md new file mode 100644 index 00000000000..fc5c76039d2 --- /dev/null +++ b/.changeset/vast-clubs-speak.md @@ -0,0 +1,31 @@ +--- +'@clerk/nextjs': minor +--- + +Added Content Security Policy (CSP) header generation functionality to `clerkMiddleware` with support for both standard and strict-dynamic modes. Key features: + +- Automatic generation of CSP headers with default security policies compatible with Clerk requirements +- Support for both standard and strict-dynamic CSP modes +- Automatic nonce generation for strict-dynamic mode +- Ability to add custom directives to match project requirements + +Example + +``` +export default clerkMiddleware( + async (auth, request) => { + if (!isPublicRoute(request)) { + await auth.protect(); + } + }, + { + contentSecurityPolicy: { + mode: "strict-dynamic", + directives: { + "connect-src": ["external.api.com"], + "script-src": ["external.scripts.com"] + } + } + } +); +``` \ No newline at end of file diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 8c91306b564..20a1bde1ee3 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -36,29 +36,31 @@ const QueryParameters = { } as const; const Headers = { - AuthToken: 'x-clerk-auth-token', + Accept: 'accept', + AuthMessage: 'x-clerk-auth-message', + Authorization: 'authorization', + AuthReason: 'x-clerk-auth-reason', AuthSignature: 'x-clerk-auth-signature', AuthStatus: 'x-clerk-auth-status', - AuthReason: 'x-clerk-auth-reason', - AuthMessage: 'x-clerk-auth-message', - ClerkUrl: 'x-clerk-clerk-url', - EnableDebug: 'x-clerk-debug', - ClerkRequestData: 'x-clerk-request-data', + AuthToken: 'x-clerk-auth-token', + CacheControl: 'cache-control', ClerkRedirectTo: 'x-clerk-redirect-to', + ClerkRequestData: 'x-clerk-request-data', + ClerkUrl: 'x-clerk-clerk-url', CloudFrontForwardedProto: 'cloudfront-forwarded-proto', - Authorization: 'authorization', + ContentType: 'content-type', + ContentSecurityPolicy: 'content-security-policy', + EnableDebug: 'x-clerk-debug', + ForwardedHost: 'x-forwarded-host', ForwardedPort: 'x-forwarded-port', ForwardedProto: 'x-forwarded-proto', - ForwardedHost: 'x-forwarded-host', - Accept: 'accept', - Referrer: 'referer', - UserAgent: 'user-agent', - Origin: 'origin', Host: 'host', - ContentType: 'content-type', - SecFetchDest: 'sec-fetch-dest', Location: 'location', - CacheControl: 'cache-control', + Nonce: 'x-nonce', + Origin: 'origin', + Referrer: 'referer', + SecFetchDest: 'sec-fetch-dest', + UserAgent: 'user-agent', } as const; const ContentTypes = { diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap index 7f7779d7eed..57548584886 100644 --- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap @@ -4,39 +4,6 @@ exports[`constants from environment variables 1`] = ` { "API_URL": "CLERK_API_URL", "API_VERSION": "CLERK_API_VERSION", - "Cookies": { - "ClientUat": "__client_uat", - "DevBrowser": "__clerk_db_jwt", - "Handshake": "__clerk_handshake", - "RedirectCount": "__clerk_redirect_count", - "Refresh": "__refresh", - "Session": "__session", - }, - "Headers": { - "Accept": "accept", - "AuthMessage": "x-clerk-auth-message", - "AuthReason": "x-clerk-auth-reason", - "AuthSignature": "x-clerk-auth-signature", - "AuthStatus": "x-clerk-auth-status", - "AuthToken": "x-clerk-auth-token", - "Authorization": "authorization", - "CacheControl": "cache-control", - "ClerkRedirectTo": "x-clerk-redirect-to", - "ClerkRequestData": "x-clerk-request-data", - "ClerkUrl": "x-clerk-clerk-url", - "CloudFrontForwardedProto": "cloudfront-forwarded-proto", - "ContentType": "content-type", - "EnableDebug": "x-clerk-debug", - "ForwardedHost": "x-forwarded-host", - "ForwardedPort": "x-forwarded-port", - "ForwardedProto": "x-forwarded-proto", - "Host": "host", - "Location": "location", - "Origin": "origin", - "Referrer": "referer", - "SecFetchDest": "sec-fetch-dest", - "UserAgent": "user-agent", - }, "JWT_KEY": "CLERK_JWT_KEY", "PUBLISHABLE_KEY": "CLERK_PUBLISHABLE_KEY", "SDK_METADATA": { diff --git a/packages/fastify/src/__tests__/constants.test.ts b/packages/fastify/src/__tests__/constants.test.ts index 6160e7b06ba..45594db2d67 100644 --- a/packages/fastify/src/__tests__/constants.test.ts +++ b/packages/fastify/src/__tests__/constants.test.ts @@ -21,6 +21,13 @@ describe('constants', () => { test('from environment variables', () => { jest.resetModules(); - expect(constants).toMatchSnapshot(); + const { Headers, Cookies, ...localConstants } = constants; + + // Verify imported constants exist but don't snapshot them + expect(Headers).toBeDefined(); + expect(Cookies).toBeDefined(); + + // Only snapshot our local constants + expect(localConstants).toMatchSnapshot(); }); }); diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 029b198a3cc..19e18261db6 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -19,8 +19,13 @@ const getDynamicClerkState = React.cache(async function getDynamicClerkState() { return data; }); -const getNonceFromCSPHeader = React.cache(async function getNonceFromCSPHeader() { - return getScriptNonceFromHeader((await headers()).get('Content-Security-Policy') || '') || ''; +const getNonceHeaders = React.cache(async function getNonceHeaders() { + const headersList = await headers(); + const nonce = headersList.get('X-Nonce'); + return nonce + ? nonce + : // Fallback to extracting from CSP header + getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; }); export async function ClerkProvider( @@ -51,9 +56,9 @@ export async function ClerkProvider( * For some reason, Next 13 requires that functions which call `headers()` are awaited where they are invoked. * Without the await here, Next will throw a DynamicServerError during build. */ - return Promise.resolve(await getNonceFromCSPHeader()); + return Promise.resolve(await getNonceHeaders()); } - return getNonceFromCSPHeader(); + return getNonceHeaders(); } const propsWithEnvs = mergeNextClerkPropsWithEnv({ diff --git a/packages/nextjs/src/server/__tests__/content-security-policy.test.ts b/packages/nextjs/src/server/__tests__/content-security-policy.test.ts new file mode 100644 index 00000000000..7ab58f52a9f --- /dev/null +++ b/packages/nextjs/src/server/__tests__/content-security-policy.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createCSPHeader, generateNonce } from '../content-security-policy'; + +describe('CSP Header Utils', () => { + describe('generateNonce', () => { + it('should generate a base64 nonce of correct length', () => { + const nonce = generateNonce(); + expect(nonce).toMatch(/^[A-Za-z0-9+/=]+$/); // Base64 pattern + expect(Buffer.from(nonce, 'base64')).toHaveLength(16); + }); + + it('should generate unique nonces', () => { + const nonce1 = generateNonce(); + const nonce2 = generateNonce(); + expect(nonce1).not.toBe(nonce2); + }); + }); + + describe('createCSPHeader', () => { + const testHost = 'clerk.example.com'; + + it('should create a standard CSP header with default directives', () => { + const result = createCSPHeader('standard', testHost); + + const directives = result.header.split('; '); + + expect(directives).toContainEqual("default-src 'self'"); + expect(directives).toContainEqual( + "connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com", + ); + expect(directives).toContainEqual("form-action 'self'"); + expect(directives).toContainEqual( + "frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com", + ); + expect(directives).toContainEqual("img-src 'self' https://img.clerk.com"); + expect(directives).toContainEqual("style-src 'self' 'unsafe-inline'"); + expect(directives).toContainEqual("worker-src 'self' blob:"); + + // script-src can vary based on env, so check each value separately + const scriptSrc = directives.find(d => d.startsWith('script-src')); + expect(scriptSrc).toBeDefined(); + expect(scriptSrc).toContain("'self'"); + expect(scriptSrc).toContain("'unsafe-inline'"); + expect(scriptSrc).toContain('https:'); + expect(scriptSrc).toContain('http:'); + expect(scriptSrc).toContain('https://*.js.stripe.com'); + expect(scriptSrc).toContain('https://js.stripe.com'); + expect(scriptSrc).toContain('https://maps.googleapis.com'); + if (process.env.NODE_ENV !== 'production') { + expect(scriptSrc).toContain("'unsafe-eval'"); + } + + expect(result.nonce).toBeUndefined(); + }); + + it('should create a strict-dynamic CSP header with nonce', () => { + const result = createCSPHeader('strict-dynamic', testHost); + + // Extract the script-src directive and verify it contains the required values + const directives = result.header.split('; '); + const scriptSrcDirective = directives.find((d: string) => d.startsWith('script-src')) ?? ''; + expect(scriptSrcDirective).toBeDefined(); + + const scriptSrcValues = scriptSrcDirective.replace('script-src ', '').split(' '); + expect(scriptSrcValues).toContain("'self'"); + expect(scriptSrcValues).toContain("'unsafe-inline'"); + expect(scriptSrcValues).toContain("'strict-dynamic'"); + expect(scriptSrcValues.some(val => val.startsWith("'nonce-"))).toBe(true); + + expect(result.nonce).toBeDefined(); + expect(result.nonce).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + it('should handle custom directives as an object', () => { + const customDirectives = { + 'default-src': ['none'], + 'img-src': ['self', 'https://example.com'], + 'custom-directive': ['value'], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + expect(result.header).toContain("default-src 'none'"); + // Check for the presence of all required values in the img-src directive + const directives = result.header.split('; '); + const imgSrcDirective = directives.find(d => d.startsWith('img-src')); + expect(imgSrcDirective).toBeDefined(); + expect(imgSrcDirective).toContain("'self'"); + expect(imgSrcDirective).toContain('https://img.clerk.com'); + expect(imgSrcDirective).toContain('https://example.com'); + expect(result.header).toContain('custom-directive value'); + }); + + it('should handle development environment specific directives', () => { + const result = createCSPHeader('standard', testHost); + const directives = result.header.split('; '); + const scriptSrcDirective = directives.find(d => d.startsWith('script-src')); + expect(scriptSrcDirective).toBeDefined(); + expect(scriptSrcDirective).toContain("'self'"); + expect(scriptSrcDirective).toContain("'unsafe-eval'"); + expect(scriptSrcDirective).toContain("'unsafe-inline'"); + expect(scriptSrcDirective).toContain('https:'); + expect(scriptSrcDirective).toContain('http:'); + expect(scriptSrcDirective).toContain('https://*.js.stripe.com'); + expect(scriptSrcDirective).toContain('https://js.stripe.com'); + expect(scriptSrcDirective).toContain('https://maps.googleapis.com'); + }); + + it('preserves all original CLERK_CSP_VALUES directives with special keywords quoted', () => { + const result = createCSPHeader('standard', testHost); + + // Split the result into individual directives for precise testing + const directives = result.header.split('; '); + + // Check each directive individually with exact matches + expect(directives).toContainEqual( + "connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com", + ); + expect(directives).toContainEqual("default-src 'self'"); + expect(directives).toContainEqual("form-action 'self'"); + expect(directives).toContainEqual( + "frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com", + ); + expect(directives).toContainEqual("img-src 'self' https://img.clerk.com"); + expect(directives).toContainEqual("style-src 'self' 'unsafe-inline'"); + expect(directives).toContainEqual("worker-src 'self' blob:"); + + // script-src varies based on NODE_ENV, so we check for common values + const scriptSrc = directives.find((d: string) => d.startsWith('script-src')); + expect(scriptSrc).toBeDefined(); + expect(scriptSrc).toContain("'self'"); + expect(scriptSrc).toContain('https:'); + expect(scriptSrc).toContain('http:'); + expect(scriptSrc).toContain("'unsafe-inline'"); + // 'unsafe-eval' depends on NODE_ENV, so we verify conditionally + if (process.env.NODE_ENV !== 'production') { + expect(scriptSrc).toContain("'unsafe-eval'"); + } + }); + + it('includes script-src with development-specific values when NODE_ENV is not production', () => { + vi.stubEnv('NODE_ENV', 'development'); + + const result = createCSPHeader('standard', testHost); + const directives = result.header.split('; '); + + const scriptSrc = directives.find((d: string) => d.startsWith('script-src')); + expect(scriptSrc).toBeDefined(); + + // In development, script-src should include 'unsafe-eval' + expect(scriptSrc).toContain("'unsafe-eval'"); + expect(scriptSrc).toContain("'self'"); + expect(scriptSrc).toContain('https:'); + expect(scriptSrc).toContain('http:'); + expect(scriptSrc).toContain("'unsafe-inline'"); + + vi.stubEnv('NODE_ENV', 'production'); + }); + + it('properly converts host to clerk subdomain in CSP directives', () => { + const host = 'clerk.example.com'; + const result = createCSPHeader('standard', host); + + // Split the result into individual directives for precise testing + const directives = result.header.split('; '); + + // When full URL is provided, it should be parsed to clerk.domain.tld in all relevant directives + expect(directives).toContainEqual( + `connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com`, + ); + expect(directives).toContainEqual(`img-src 'self' https://img.clerk.com`); + expect(directives).toContainEqual( + `frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com`, + ); + + // Check that other directives are present but don't contain the clerk subdomain + expect(directives).toContainEqual(`default-src 'self'`); + expect(directives).toContainEqual(`form-action 'self'`); + expect(directives).toContainEqual(`style-src 'self' 'unsafe-inline'`); + expect(directives).toContainEqual(`worker-src 'self' blob:`); + }); + + it('merges and deduplicates values for existing directives while preserving special keywords', () => { + const customDirectives = { + 'script-src': ["'self'", 'new-value', 'another-value', "'unsafe-inline'", "'unsafe-eval'"], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + // The script-src directive should contain both the default values and new values, with special keywords quoted + const resultDirectives = result.header.split('; '); + const scriptSrcDirective = resultDirectives.find((d: string) => d.startsWith('script-src')) ?? ''; + expect(scriptSrcDirective).toBeDefined(); + + // Verify it contains all expected values exactly once + const values = new Set(scriptSrcDirective.replace('script-src ', '').split(' ')); + expect(values).toContain("'self'"); + expect(values).toContain("'unsafe-inline'"); + expect(values).toContain('new-value'); + expect(values).toContain('another-value'); + }); + + it('correctly adds new directives from custom directives object and preserves special keyword quoting', () => { + const customDirectives = { + 'object-src': ['self', 'value1', 'value2', 'unsafe-inline'], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + // The new directive should be added + const directives = result.header.split('; '); + const newDirective = directives.find((d: string) => d.startsWith('object-src')) ?? ''; + expect(newDirective).toBeDefined(); + + const newDirectiveValues = newDirective.replace('object-src ', '').split(' '); + expect(newDirectiveValues).toContain("'self'"); + expect(newDirectiveValues).toContain('value1'); + expect(newDirectiveValues).toContain('value2'); + expect(newDirectiveValues).toContain("'unsafe-inline'"); + }); + + it('produces a complete CSP header with all expected directives and special keywords quoted', () => { + const customDirectives = { + 'script-src': ['new-value', 'unsafe-inline'], + 'object-src': ['self', 'value1', 'value2'], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + // Split the result into individual directives for precise testing + const directives = result.header.split('; '); + + // Verify all directives are present with their exact values, with special keywords quoted + expect(directives).toContainEqual( + "connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com", + ); + expect(directives).toContainEqual("default-src 'self'"); + expect(directives).toContainEqual("form-action 'self'"); + expect(directives).toContainEqual( + "frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com", + ); + expect(directives).toContainEqual("img-src 'self' https://img.clerk.com"); + expect(directives).toContainEqual("style-src 'self' 'unsafe-inline'"); + expect(directives).toContainEqual("worker-src 'self' blob:"); + + // Verify the new directive exists and has expected values + const newDirective = directives.find((d: string) => d.startsWith('object-src')) ?? ''; + expect(newDirective).toBeDefined(); + + const newDirectiveValues = newDirective.replace('object-src ', '').split(' '); + expect(newDirectiveValues).toContain("'self'"); + expect(newDirectiveValues).toContain('value1'); + expect(newDirectiveValues).toContain('value2'); + + // Extract the script-src directive and check for each expected value individually + const scriptSrcDirective = directives.find((d: string) => d.startsWith('script-src')) ?? ''; + expect(scriptSrcDirective).toBeDefined(); + + // Verify it contains all expected values regardless of order + const scriptSrcValues = scriptSrcDirective.replace('script-src ', '').split(' '); + expect(scriptSrcValues).toContain("'self'"); + expect(scriptSrcValues).toContain("'unsafe-inline'"); + expect(scriptSrcValues).toContain('https:'); + expect(scriptSrcValues).toContain('http:'); + expect(scriptSrcValues).toContain('new-value'); + + // Verify the header format (directives separated by semicolons) + expect(result.header).toMatch(/^[^;]+(; [^;]+)*$/); + }); + + it('automatically quotes special keywords in CSP directives regardless of input format', () => { + vi.stubEnv('NODE_ENV', 'development'); + const customDirectives = { + 'script-src': ['self', 'unsafe-inline', 'unsafe-eval', 'custom-domain.com'], + 'new-directive': ['none'], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + // Verify that special keywords are always quoted in output, regardless of input format + const resultDirectives = result.header.split('; '); + + // Verify script-src directive has properly quoted keywords + const scriptSrcDirective = resultDirectives.find((d: string) => d.startsWith('script-src')) ?? ''; + expect(scriptSrcDirective).toBeDefined(); + const scriptSrcValues = scriptSrcDirective.replace('script-src ', '').split(' '); + expect(scriptSrcValues).toContain("'self'"); + expect(scriptSrcValues).toContain("'unsafe-inline'"); + expect(scriptSrcValues).toContain("'unsafe-eval'"); + expect(scriptSrcValues).toContain('custom-domain.com'); + // Verify default values for standard mode + expect(scriptSrcValues).toContain('http:'); + expect(scriptSrcValues).toContain('https:'); + + // Verify new-directive has properly quoted keywords and is the sole value + const newDirective = resultDirectives.find((d: string) => d.startsWith('new-directive')) ?? ''; + expect(newDirective).toBeDefined(); + const newDirectiveValues = newDirective.replace('new-directive ', '').split(' '); + expect(newDirectiveValues).toContain("'none'"); + expect(newDirectiveValues).toHaveLength(1); + + vi.stubEnv('NODE_ENV', 'production'); + }); + + it('correctly merges clerk subdomain with existing CSP values', () => { + const customDirectives = { + 'connect-src': ['self', 'https://api.example.com'], + 'img-src': ['self', 'https://images.example.com'], + 'frame-src': ['self', 'https://frames.example.com'], + }; + const result = createCSPHeader('standard', testHost, customDirectives); + + const directives = result.header.split('; '); + + // Verify clerk subdomain is added while preserving existing values + expect(directives).toContainEqual( + `connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com https://api.example.com`, + ); + // Verify all required domains are present in the img-src directive + const imgSrcDirective = directives.find(d => d.startsWith('img-src')) || ''; + expect(imgSrcDirective).toBeDefined(); + expect(imgSrcDirective).toContain("'self'"); + expect(imgSrcDirective).toContain('https://img.clerk.com'); + expect(imgSrcDirective).toContain('https://images.example.com'); + expect(directives).toContainEqual( + `frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com https://frames.example.com`, + ); + + // Verify other directives are present and unchanged + expect(directives).toContainEqual(`default-src 'self'`); + expect(directives).toContainEqual(`form-action 'self'`); + expect(directives).toContainEqual(`style-src 'self' 'unsafe-inline'`); + expect(directives).toContainEqual(`worker-src 'self' blob:`); + }); + + it('correctly implements strict-dynamic mode with nonce-based script-src', () => { + const result = createCSPHeader('strict-dynamic', testHost); + const directives = result.header.split('; '); + + // Extract the script-src directive and check for specific values + const scriptSrcDirective = directives.find((d: string) => d.startsWith('script-src')) ?? ''; + expect(scriptSrcDirective).toBeDefined(); + + // In strict-dynamic mode, script-src should contain 'strict-dynamic' and a nonce + const scriptSrcValues = scriptSrcDirective.replace('script-src ', '').split(' '); + expect(scriptSrcValues).toContain("'strict-dynamic'"); + expect(scriptSrcValues.some(val => val.startsWith("'nonce-"))).toBe(true); + + // Should not contain http: or https: in strict-dynamic mode + expect(scriptSrcValues).not.toContain('http:'); + expect(scriptSrcValues).not.toContain('https:'); + + // Other directives should still be present + expect(directives).toContainEqual( + "connect-src 'self' https://clerk-telemetry.com https://*.clerk-telemetry.com https://api.stripe.com https://maps.googleapis.com clerk.example.com", + ); + expect(directives).toContainEqual("default-src 'self'"); + expect(directives).toContainEqual("form-action 'self'"); + expect(directives).toContainEqual( + "frame-src 'self' https://challenges.cloudflare.com https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com", + ); + expect(directives).toContainEqual("img-src 'self' https://img.clerk.com"); + expect(directives).toContainEqual("style-src 'self' 'unsafe-inline'"); + expect(directives).toContainEqual("worker-src 'self' blob:"); + }); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 9b093db3802..45a21bcc637 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,6 +1,7 @@ import type { AuthObject, ClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, ClerkRequest, RedirectFun, RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal'; +import { parsePublishableKey } from '@clerk/shared/keys'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -10,6 +11,7 @@ import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; import { clerkClient } from './clerkClient'; import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; +import { createCSPHeader, type CSPDirective, type CSPMode } from './content-security-policy'; import { errorThrower } from './errorThrower'; import { getKeylessCookieValue } from './keyless'; import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } from './middleware-storage'; @@ -56,6 +58,20 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { * If true, additional debug information will be logged to the console. */ debug?: boolean; + + /** + * When set, automatically injects a Content-Security-Policy header(s) compatible with Clerk. + */ + contentSecurityPolicy?: { + /** + * The CSP mode to use - either 'standard' or 'strict-dynamic' + */ + mode: CSPMode; + /** + * Custom CSP directives to merge with Clerk's default directives + */ + directives?: Partial>; + }; }; type ClerkMiddlewareOptionsCallback = (req: NextRequest) => ClerkMiddlewareOptions | Promise; @@ -134,11 +150,18 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl logger.debug('options', options); logger.debug('url', () => clerkRequest.toJSON()); - const authHeader = request.headers.get('authorization'); + const authHeader = request.headers.get(constants.Headers.Authorization); if (authHeader && authHeader.startsWith('Basic ')) { logger.debug('Basic Auth detected'); } + const cspHeader = request.headers.get(constants.Headers.ContentSecurityPolicy); + if (cspHeader) { + logger.debug('Content-Security-Policy detected', () => ({ + value: cspHeader, + })); + } + const requestState = await resolvedClerkClient.authenticateRequest( clerkRequest, createAuthenticateRequestOptions(clerkRequest, options), @@ -178,11 +201,33 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl } catch (e: any) { handlerResult = handleControlFlowErrors(e, clerkRequest, request, requestState); } + if (options.contentSecurityPolicy) { + const { header, nonce } = createCSPHeader( + options.contentSecurityPolicy.mode, + (parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''), + options.contentSecurityPolicy.directives, + ); + + setHeader(handlerResult, constants.Headers.ContentSecurityPolicy, header); + if (nonce) { + setHeader(handlerResult, constants.Headers.Nonce, nonce); + } + + logger.debug('Clerk generated CSP', () => ({ + header, + nonce, + })); + } // TODO @nikos: we need to make this more generic // and move the logic in clerk/backend if (requestState.headers) { requestState.headers.forEach((value, key) => { + if (key === constants.Headers.ContentSecurityPolicy) { + logger.debug('Content-Security-Policy detected', () => ({ + value, + })); + } handlerResult.headers.append(key, value); }); } diff --git a/packages/nextjs/src/server/content-security-policy.ts b/packages/nextjs/src/server/content-security-policy.ts new file mode 100644 index 00000000000..8fb88d03361 --- /dev/null +++ b/packages/nextjs/src/server/content-security-policy.ts @@ -0,0 +1,305 @@ +/** + * Valid CSP directives according to the CSP Level 3 specification + */ +export type CSPDirective = + // Default resource directives + | 'connect-src' + | 'default-src' + | 'font-src' + | 'img-src' + | 'media-src' + | 'object-src' + | 'script-src' + | 'style-src' + // Framing and navigation directives + | 'base-uri' + | 'child-src' + | 'form-action' + | 'frame-ancestors' + | 'frame-src' + | 'manifest-src' + | 'navigate-to' + | 'prefetch-src' + | 'worker-src' + // Sandbox and plugin directives + | 'plugin-types' + | 'require-sri-for' + | 'sandbox' + // Trusted types and upgrade directives + | 'block-all-mixed-content' + | 'require-trusted-types-for' + | 'trusted-types' + | 'upgrade-insecure-requests' + // Reporting directives + | 'report-to' + | 'report-uri' + // CSP Level 3 additional directives + | 'script-src-attr' + | 'script-src-elem' + | 'style-src-attr' + | 'style-src-elem'; + +/** + * The mode to use for generating the CSP header + * + * - `standard`: Standard CSP mode + * - `strict-dynamic`: Strict-dynamic mode, also generates a nonce + */ +export type CSPMode = 'standard' | 'strict-dynamic'; + +/** + * Partial record of directives and their values + */ +type CSPValues = Partial>; + +/** + * Directives and their values + */ +type CSPDirectiveSet = Record>; + +/** + * Return type for createCSPHeader + */ +export interface CSPHeaderResult { + /** The formatted CSP header string */ + header: string; + /** The generated nonce, if applicable */ + nonce?: string; +} + +class CSPDirectiveManager { + /** Set of special keywords that require quoting in CSP directives */ + private static readonly KEYWORDS = new Set([ + 'none', + 'self', + 'strict-dynamic', + 'unsafe-eval', + 'unsafe-hashes', + 'unsafe-inline', + ]); + + /** Default CSP directives and their values */ + static readonly DEFAULT_DIRECTIVES: CSPValues = { + 'connect-src': [ + 'self', + 'https://clerk-telemetry.com', + 'https://*.clerk-telemetry.com', + 'https://api.stripe.com', + 'https://maps.googleapis.com', + ], + 'default-src': ['self'], + 'form-action': ['self'], + 'frame-src': [ + 'self', + 'https://challenges.cloudflare.com', + 'https://*.js.stripe.com', + 'https://js.stripe.com', + 'https://hooks.stripe.com', + ], + 'img-src': ['self', 'https://img.clerk.com'], + 'script-src': [ + 'self', + ...(process.env.NODE_ENV !== 'production' ? ['unsafe-eval'] : []), + 'unsafe-inline', + 'https:', + 'http:', + 'https://*.js.stripe.com', + 'https://js.stripe.com', + 'https://maps.googleapis.com', + ], + 'style-src': ['self', 'unsafe-inline'], + 'worker-src': ['self', 'blob:'], + }; + + /** + * Creates a new CSPDirectiveSet with default values + * @returns A new CSPDirectiveSet with default values + */ + static createDefaultDirectives(): CSPDirectiveSet { + return Object.entries(this.DEFAULT_DIRECTIVES).reduce((acc, [key, values]) => { + acc[key as CSPDirective] = new Set(values); + return acc; + }, {} as CSPDirectiveSet); + } + + /** + * Checks if a value is a special keyword that requires quoting + * @param value - The value to check + * @returns True if the value is a special keyword + */ + static isKeyword(value: string): boolean { + return this.KEYWORDS.has(value.replace(/^'|'$/g, '')); + } + + /** + * Formats a value according to CSP rules, adding quotes for special keywords + * @param value - The value to format + * @returns The formatted value + */ + static formatValue(value: string): string { + const unquoted = value.replace(/^'|'$/g, ''); + return this.isKeyword(unquoted) ? `'${unquoted}'` : value; + } + + /** + * Handles directive values, ensuring proper formatting and special case handling + * @param values - Array of values to process + * @returns Set of formatted values + */ + static handleDirectiveValues(values: string[]): Set { + const result = new Set(); + + if (values.includes("'none'") || values.includes('none')) { + result.add("'none'"); + return result; + } + + values.forEach(v => result.add(this.formatValue(v))); + return result; + } +} + +/** + * Handles merging of existing directives with new values + * @param mergedCSP - The current merged CSP state + * @param key - The directive key to handle + * @param values - New values to merge + */ +function handleExistingDirective(mergedCSP: CSPDirectiveSet, key: CSPDirective, values: string[]) { + // None overrides all other values + if (values.includes("'none'") || values.includes('none')) { + mergedCSP[key] = new Set(["'none'"]); + return; + } + + // For existing directives, merge the values rather than replacing + const deduplicatedSet = new Set(); + + mergedCSP[key].forEach(value => { + deduplicatedSet.add(CSPDirectiveManager.formatValue(value)); + }); + + values.forEach(value => { + deduplicatedSet.add(CSPDirectiveManager.formatValue(value)); + }); + + mergedCSP[key] = deduplicatedSet; +} + +/** + * Handles custom directives that are not part of the default set + * @param customDirectives - Map of custom directives + * @param key - The directive key + * @param values - Values for the directive + */ +function handleCustomDirective(customDirectives: Map>, key: string, values: string[]) { + // None overrides all other values + if (values.includes("'none'") || values.includes('none')) { + customDirectives.set(key, new Set(["'none'"])); + return; + } + + const formattedValues = new Set(); + values.forEach(value => { + const formattedValue = CSPDirectiveManager.formatValue(value); + formattedValues.add(formattedValue); + }); + + customDirectives.set(key, formattedValues); +} + +/** + * Applies formatting to the CSP header + * @param mergedCSP - The merged CSP state to format + * @returns Formatted CSP header string + */ +function formatCSPHeader(mergedCSP: Record>): string { + return Object.entries(mergedCSP) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, values]) => { + const valueObjs = Array.from(values).map(v => ({ + raw: v, + formatted: CSPDirectiveManager.formatValue(v), + })); + + return `${key} ${valueObjs.map(item => item.formatted).join(' ')}`; + }) + .join('; '); +} + +/** + * Generates a secure random nonce for CSP headers + * @returns A base64-encoded random nonce + */ +export function generateNonce(): string { + const randomBytes = new Uint8Array(16); + crypto.getRandomValues(randomBytes); + const binaryString = Array.from(randomBytes, byte => String.fromCharCode(byte)).join(''); + return btoa(binaryString); +} + +/** + * Creates a merged CSP state with all necessary directives + * @param mode - The CSP mode to use + * @param host - The host to include in CSP + * @param customDirectives - Optional custom directives to merge with + * @param nonce - Optional nonce for strict-dynamic mode + * @returns Merged CSPDirectiveSet + */ +function createMergedCSP( + mode: CSPMode, + host: string, + customDirectives?: Record, + nonce?: string, +): Record> { + // Initialize with default Clerk CSP values + const mergedCSP = CSPDirectiveManager.createDefaultDirectives(); + mergedCSP['connect-src'].add(host); + + // Handle strict-dynamic mode specific changes + if (mode === 'strict-dynamic') { + mergedCSP['script-src'].delete('http:'); + mergedCSP['script-src'].delete('https:'); + mergedCSP['script-src'].add("'strict-dynamic'"); + if (nonce) { + mergedCSP['script-src'].add(`'nonce-${nonce}'`); + } + } + + // Add custom directives if provided + const customDirectivesMap = new Map>(); + if (customDirectives) { + Object.entries(customDirectives).forEach(([key, values]) => { + const valuesArray = Array.isArray(values) ? values : [values]; + if (CSPDirectiveManager.DEFAULT_DIRECTIVES[key as CSPDirective]) { + handleExistingDirective(mergedCSP, key as CSPDirective, valuesArray); + } else { + handleCustomDirective(customDirectivesMap, key, valuesArray); + } + }); + } + + // Combine standard directives with custom directives + const finalCSP: Record> = { ...mergedCSP }; + customDirectivesMap.forEach((values, key) => { + finalCSP[key] = values; + }); + + return finalCSP; +} + +/** + * Creates a Content Security Policy (CSP) header with the specified mode and host + * @param mode - The CSP mode to use ('standard' or 'strict-dynamic') + * @param host - The host to include in the CSP (parsed from publishableKey) + * @param customDirectives - Optional custom directives to merge with + * @returns Object containing the formatted CSP header and nonce (if in strict-dynamic mode) + */ +export function createCSPHeader(mode: CSPMode, host: string, customDirectives?: CSPValues): CSPHeaderResult { + const nonce = mode === 'strict-dynamic' ? generateNonce() : undefined; + + return { + header: formatCSPHeader(createMergedCSP(mode, host, customDirectives, nonce)), + nonce, + }; +}