Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/free-crews-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': minor
---

Adjust the CSP configuration option from mode to boolean strict to make it more intuitive
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('CSP Header Utils', () => {
const testHost = 'clerk.example.com';

it('should create a standard CSP header with default directives', () => {
const result = createCSPHeader('standard', testHost);
const result = createCSPHeader(false, testHost);

const directives = result.header.split('; ');

Expand Down Expand Up @@ -55,7 +55,7 @@ describe('CSP Header Utils', () => {
});

it('should create a strict-dynamic CSP header with nonce', () => {
const result = createCSPHeader('strict-dynamic', testHost);
const result = createCSPHeader(true, testHost);

// Extract the script-src directive and verify it contains the required values
const directives = result.header.split('; ');
Expand All @@ -78,7 +78,7 @@ describe('CSP Header Utils', () => {
'img-src': ['self', 'https://example.com'],
'custom-directive': ['value'],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

expect(result.header).toContain("default-src 'none'");
// Check for the presence of all required values in the img-src directive
Expand All @@ -92,7 +92,7 @@ describe('CSP Header Utils', () => {
});

it('should handle development environment specific directives', () => {
const result = createCSPHeader('standard', testHost);
const result = createCSPHeader(false, testHost);
const directives = result.header.split('; ');
const scriptSrcDirective = directives.find(d => d.startsWith('script-src'));
expect(scriptSrcDirective).toBeDefined();
Expand All @@ -107,7 +107,7 @@ describe('CSP Header Utils', () => {
});

it('preserves all original CLERK_CSP_VALUES directives with special keywords quoted', () => {
const result = createCSPHeader('standard', testHost);
const result = createCSPHeader(false, testHost);

// Split the result into individual directives for precise testing
const directives = result.header.split('; ');
Expand Down Expand Up @@ -141,7 +141,7 @@ describe('CSP Header Utils', () => {
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 result = createCSPHeader(false, testHost);
const directives = result.header.split('; ');

const scriptSrc = directives.find((d: string) => d.startsWith('script-src'));
Expand All @@ -159,7 +159,7 @@ describe('CSP Header Utils', () => {

it('properly converts host to clerk subdomain in CSP directives', () => {
const host = 'clerk.example.com';
const result = createCSPHeader('standard', host);
const result = createCSPHeader(false, host);

// Split the result into individual directives for precise testing
const directives = result.header.split('; ');
Expand All @@ -184,7 +184,7 @@ describe('CSP Header Utils', () => {
const customDirectives = {
'script-src': ["'self'", 'new-value', 'another-value', "'unsafe-inline'", "'unsafe-eval'"],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

// The script-src directive should contain both the default values and new values, with special keywords quoted
const resultDirectives = result.header.split('; ');
Expand All @@ -203,7 +203,7 @@ describe('CSP Header Utils', () => {
const customDirectives = {
'object-src': ['self', 'value1', 'value2', 'unsafe-inline'],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

// The new directive should be added
const directives = result.header.split('; ');
Expand All @@ -222,7 +222,7 @@ describe('CSP Header Utils', () => {
'script-src': ['new-value', 'unsafe-inline'],
'object-src': ['self', 'value1', 'value2'],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

// Split the result into individual directives for precise testing
const directives = result.header.split('; ');
Expand Down Expand Up @@ -271,7 +271,7 @@ describe('CSP Header Utils', () => {
'script-src': ['self', 'unsafe-inline', 'unsafe-eval', 'custom-domain.com'],
'new-directive': ['none'],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

// Verify that special keywords are always quoted in output, regardless of input format
const resultDirectives = result.header.split('; ');
Expand Down Expand Up @@ -304,7 +304,7 @@ describe('CSP Header Utils', () => {
'img-src': ['self', 'https://images.example.com'],
'frame-src': ['self', 'https://frames.example.com'],
};
const result = createCSPHeader('standard', testHost, customDirectives);
const result = createCSPHeader(false, testHost, customDirectives);

const directives = result.header.split('; ');

Expand All @@ -330,7 +330,7 @@ describe('CSP Header Utils', () => {
});

it('correctly implements strict-dynamic mode with nonce-based script-src', () => {
const result = createCSPHeader('strict-dynamic', testHost);
const result = createCSPHeader(true, testHost);
const directives = result.header.split('; ');

// Extract the script-src directive and check for specific values
Expand Down
9 changes: 5 additions & 4 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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 { createCSPHeader, type CSPDirective } from './content-security-policy';
import { errorThrower } from './errorThrower';
import { getKeylessCookieValue } from './keyless';
import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } from './middleware-storage';
Expand Down Expand Up @@ -67,9 +67,10 @@ export type ClerkMiddlewareOptions = AuthenticateRequestOptions & {
*/
contentSecurityPolicy?: {
/**
* The CSP mode to use - either 'standard' or 'strict-dynamic'
* When set to true, enhances security by applying the `strict-dynamic` attribute to the `script-src` CSP directive and generates a unique nonce value to be used for script elements.
* This helps prevent XSS attacks while allowing trusted scripts to execute.
*/
mode: CSPMode;
strict: boolean;
/**
* Custom CSP directives to merge with Clerk's default directives
*/
Expand Down Expand Up @@ -210,7 +211,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
}
if (options.contentSecurityPolicy) {
const { header, nonce } = createCSPHeader(
options.contentSecurityPolicy.mode,
options.contentSecurityPolicy.strict,
(parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''),
options.contentSecurityPolicy.directives,
);
Expand Down
22 changes: 7 additions & 15 deletions packages/nextjs/src/server/content-security-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ export type CSPDirective =
| '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
*/
Expand Down Expand Up @@ -240,14 +232,14 @@ export function generateNonce(): string {

/**
* Creates a merged CSP state with all necessary directives
* @param mode - The CSP mode to use
* @param strict - When set to true, enhances security by applying the `strict-dynamic` attribute to the `script-src` CSP directive
* @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,
strict: boolean,
host: string,
customDirectives?: Record<string, string[]>,
nonce?: string,
Expand All @@ -257,7 +249,7 @@ function createMergedCSP(
mergedCSP['connect-src'].add(host);

// Handle strict-dynamic mode specific changes
if (mode === 'strict-dynamic') {
if (strict) {
mergedCSP['script-src'].delete('http:');
mergedCSP['script-src'].delete('https:');
mergedCSP['script-src'].add("'strict-dynamic'");
Expand Down Expand Up @@ -290,16 +282,16 @@ function createMergedCSP(

/**
* 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 strict - When set to true, enhances security by applying the `strict-dynamic` attribute to the `script-src` CSP directive
* @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;
export function createCSPHeader(strict: boolean, host: string, customDirectives?: CSPValues): CSPHeaderResult {
const nonce = strict ? generateNonce() : undefined;

return {
header: formatCSPHeader(createMergedCSP(mode, host, customDirectives, nonce)),
header: formatCSPHeader(createMergedCSP(strict, host, customDirectives, nonce)),
nonce,
};
}