Skip to content

Commit d34205c

Browse files
authored
fix(clerk-js): Add nonce to captcha script element (#6226)
1 parent 4ab82f7 commit d34205c

File tree

6 files changed

+258
-19
lines changed

6 files changed

+258
-19
lines changed

.changeset/witty-parks-attack.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Adds Content Security Policy (CSP) nonce support to the Cloudflare Turnstile

packages/clerk-js/src/utils/__tests__/captcha.spec.ts

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile';
4+
import type { CaptchaOptions } from '../captcha/types';
45

56
describe('shouldRetryTurnstileErrorCode', () => {
67
it.each([
@@ -23,3 +24,229 @@ describe('shouldRetryTurnstileErrorCode', () => {
2324
expect(shouldRetryTurnstileErrorCode(str)).toBe(expected);
2425
});
2526
});
27+
28+
describe('Nonce support', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
vi.resetModules();
32+
});
33+
34+
describe('retrieveCaptchaInfo', () => {
35+
it('should extract nonce from clerk options when available', async () => {
36+
// Mock clerk instance with internal options
37+
const mockClerk = {
38+
__unstable__environment: {
39+
displayConfig: {
40+
captchaProvider: 'turnstile',
41+
captchaPublicKey: 'test-site-key',
42+
captchaWidgetType: 'managed',
43+
captchaPublicKeyInvisible: 'test-invisible-key',
44+
},
45+
userSettings: {
46+
signUp: {
47+
captcha_enabled: true,
48+
},
49+
},
50+
},
51+
isStandardBrowser: true,
52+
__internal_getOption: vi.fn().mockReturnValue('test-nonce-123'),
53+
};
54+
55+
const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
56+
const result = retrieveCaptchaInfo(mockClerk as any);
57+
58+
expect(mockClerk.__internal_getOption).toHaveBeenCalledWith('nonce');
59+
expect(result.nonce).toBe('test-nonce-123');
60+
expect(result.captchaSiteKey).toBe('test-site-key');
61+
expect(result.captchaProvider).toBe('turnstile');
62+
});
63+
64+
it('should return undefined nonce when not available in clerk options', async () => {
65+
const mockClerk = {
66+
__unstable__environment: {
67+
displayConfig: {
68+
captchaProvider: 'turnstile',
69+
captchaPublicKey: 'test-site-key',
70+
captchaWidgetType: 'managed',
71+
captchaPublicKeyInvisible: 'test-invisible-key',
72+
},
73+
userSettings: {
74+
signUp: {
75+
captcha_enabled: true,
76+
},
77+
},
78+
},
79+
isStandardBrowser: true,
80+
__internal_getOption: vi.fn().mockReturnValue(undefined),
81+
};
82+
83+
const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
84+
const result = retrieveCaptchaInfo(mockClerk as any);
85+
86+
expect(result.nonce).toBeUndefined();
87+
});
88+
89+
it('should handle clerk instance without __internal_getOption method', async () => {
90+
const mockClerk = {
91+
__unstable__environment: {
92+
displayConfig: {
93+
captchaProvider: 'turnstile',
94+
captchaPublicKey: 'test-site-key',
95+
captchaWidgetType: 'managed',
96+
captchaPublicKeyInvisible: 'test-invisible-key',
97+
},
98+
userSettings: {
99+
signUp: {
100+
captcha_enabled: true,
101+
},
102+
},
103+
},
104+
isStandardBrowser: true,
105+
// No __internal_getOption method
106+
};
107+
108+
const { retrieveCaptchaInfo } = await import('../captcha/retrieveCaptchaInfo');
109+
const result = retrieveCaptchaInfo(mockClerk as any);
110+
111+
expect(result.nonce).toBeUndefined();
112+
});
113+
});
114+
115+
describe('CaptchaOptions type support', () => {
116+
it('should accept nonce in CaptchaOptions type definition', () => {
117+
// This test verifies that the CaptchaOptions type includes the nonce field
118+
const validOptions: CaptchaOptions = {
119+
action: 'signup',
120+
captchaProvider: 'turnstile',
121+
closeModal: async () => {},
122+
invisibleSiteKey: 'test-invisible-key',
123+
modalContainerQuerySelector: '.modal',
124+
modalWrapperQuerySelector: '.wrapper',
125+
nonce: 'test-nonce-from-csp',
126+
openModal: async () => {},
127+
siteKey: 'test-site-key',
128+
widgetType: 'invisible',
129+
};
130+
131+
// If this compiles without TypeScript errors, the test passes
132+
expect(validOptions.nonce).toBe('test-nonce-from-csp');
133+
});
134+
135+
it('should allow undefined nonce in CaptchaOptions', () => {
136+
const validOptionsWithoutNonce: CaptchaOptions = {
137+
action: 'signup',
138+
captchaProvider: 'turnstile',
139+
invisibleSiteKey: 'test-invisible-key',
140+
siteKey: 'test-site-key',
141+
widgetType: 'invisible',
142+
// nonce is optional
143+
};
144+
145+
expect(validOptionsWithoutNonce.nonce).toBeUndefined();
146+
});
147+
});
148+
149+
describe('CaptchaChallenge nonce integration', () => {
150+
let mockClerk: any;
151+
152+
beforeEach(async () => {
153+
// Mock clerk instance
154+
mockClerk = {
155+
__unstable__environment: {
156+
displayConfig: {
157+
captchaProvider: 'turnstile',
158+
captchaPublicKey: 'test-site-key',
159+
captchaWidgetType: 'managed',
160+
captchaPublicKeyInvisible: 'test-invisible-key',
161+
},
162+
userSettings: {
163+
signUp: {
164+
captcha_enabled: true,
165+
},
166+
},
167+
},
168+
isStandardBrowser: true,
169+
__internal_getOption: vi.fn().mockReturnValue('clerk-nonce-789'),
170+
};
171+
172+
// Mock getCaptchaToken
173+
vi.doMock('../captcha/getCaptchaToken', () => ({
174+
getCaptchaToken: vi.fn().mockResolvedValue({
175+
captchaToken: 'mock-token',
176+
captchaWidgetType: 'invisible',
177+
}),
178+
}));
179+
});
180+
181+
it('should use nonce from clerk options in invisible challenge', async () => {
182+
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
183+
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');
184+
185+
const challenge = new CaptchaChallenge(mockClerk);
186+
await challenge.invisible({ action: 'signup' });
187+
188+
expect(getCaptchaToken).toHaveBeenCalledWith(
189+
expect.objectContaining({
190+
nonce: 'clerk-nonce-789',
191+
captchaProvider: 'turnstile',
192+
siteKey: 'test-invisible-key',
193+
widgetType: 'invisible',
194+
}),
195+
);
196+
});
197+
198+
it('should use nonce from clerk options in managedOrInvisible challenge', async () => {
199+
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
200+
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');
201+
202+
const challenge = new CaptchaChallenge(mockClerk);
203+
await challenge.managedOrInvisible({ action: 'verify' });
204+
205+
expect(getCaptchaToken).toHaveBeenCalledWith(
206+
expect.objectContaining({
207+
nonce: 'clerk-nonce-789',
208+
captchaProvider: 'turnstile',
209+
siteKey: 'test-site-key',
210+
widgetType: 'managed',
211+
}),
212+
);
213+
});
214+
215+
it('should prefer explicit nonce over clerk options nonce', async () => {
216+
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
217+
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');
218+
219+
const challenge = new CaptchaChallenge(mockClerk);
220+
await challenge.invisible({
221+
action: 'signup',
222+
nonce: 'explicit-nonce-override',
223+
});
224+
225+
expect(getCaptchaToken).toHaveBeenCalledWith(
226+
expect.objectContaining({
227+
nonce: 'explicit-nonce-override',
228+
}),
229+
);
230+
});
231+
232+
it('should handle missing nonce gracefully', async () => {
233+
// Mock clerk without nonce
234+
const clerkWithoutNonce = {
235+
...mockClerk,
236+
__internal_getOption: vi.fn().mockReturnValue(undefined),
237+
};
238+
239+
const { getCaptchaToken } = await import('../captcha/getCaptchaToken');
240+
const { CaptchaChallenge } = await import('../captcha/CaptchaChallenge');
241+
242+
const challenge = new CaptchaChallenge(clerkWithoutNonce);
243+
await challenge.invisible({ action: 'signup' });
244+
245+
expect(getCaptchaToken).toHaveBeenCalledWith(
246+
expect.objectContaining({
247+
nonce: undefined,
248+
}),
249+
);
250+
});
251+
});
252+
});

packages/clerk-js/src/utils/captcha/CaptchaChallenge.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ export class CaptchaChallenge {
1212
* always use the fallback key.
1313
*/
1414
public async invisible(opts?: Partial<CaptchaOptions>) {
15-
const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible } = retrieveCaptchaInfo(this.clerk);
15+
const { captchaSiteKey, canUseCaptcha, captchaPublicKeyInvisible, nonce } = retrieveCaptchaInfo(this.clerk);
1616

1717
if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
1818
const captchaResult = await getCaptchaToken({
19-
siteKey: captchaPublicKeyInvisible,
19+
action: opts?.action,
20+
captchaProvider: 'turnstile',
2021
invisibleSiteKey: captchaPublicKeyInvisible,
22+
nonce: opts?.nonce || nonce || undefined,
23+
siteKey: captchaPublicKeyInvisible,
2124
widgetType: 'invisible',
22-
captchaProvider: 'turnstile',
23-
action: opts?.action,
2425
}).catch(e => {
2526
if (e.captchaError) {
2627
return { captchaError: e.captchaError };
@@ -42,15 +43,16 @@ export class CaptchaChallenge {
4243
* Managed challenged start as non-interactive and escalate to interactive if necessary.
4344
*/
4445
public async managedOrInvisible(opts?: Partial<CaptchaOptions>) {
45-
const { captchaSiteKey, canUseCaptcha, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible } =
46+
const { captchaSiteKey, canUseCaptcha, captchaWidgetType, captchaProvider, captchaPublicKeyInvisible, nonce } =
4647
retrieveCaptchaInfo(this.clerk);
4748

4849
if (canUseCaptcha && captchaSiteKey && captchaPublicKeyInvisible) {
4950
const captchaResult = await getCaptchaToken({
51+
captchaProvider,
52+
invisibleSiteKey: captchaPublicKeyInvisible,
53+
nonce: nonce || undefined,
5054
siteKey: captchaSiteKey,
5155
widgetType: captchaWidgetType,
52-
invisibleSiteKey: captchaPublicKeyInvisible,
53-
captchaProvider,
5456
...opts,
5557
}).catch(e => {
5658
if (e.captchaError) {

packages/clerk-js/src/utils/captcha/retrieveCaptchaInfo.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ export const retrieveCaptchaInfo = (clerk: Clerk) => {
44
const _environment = clerk.__unstable__environment;
55
const captchaProvider = _environment ? _environment.displayConfig.captchaProvider : 'turnstile';
66

7+
// Access nonce via internal options - casting to any since nonce is in IsomorphicClerkOptions but not ClerkOptions
8+
const nonce = (clerk as any).__internal_getOption?.('nonce') as string | undefined;
9+
710
return {
811
captchaSiteKey: _environment ? _environment.displayConfig.captchaPublicKey : null,
912
captchaWidgetType: _environment ? _environment.displayConfig.captchaWidgetType : null,
1013
captchaProvider,
1114
captchaPublicKeyInvisible: _environment ? _environment.displayConfig.captchaPublicKeyInvisible : null,
1215
canUseCaptcha: _environment ? _environment.userSettings.signUp.captcha_enabled && clerk.isStandardBrowser : null,
16+
nonce: nonce || undefined,
1317
};
1418
};

packages/clerk-js/src/utils/captcha/turnstile.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,23 @@ export const shouldRetryTurnstileErrorCode = (errorCode: string) => {
2626
return !!codesWithRetries.find(w => errorCode.startsWith(w));
2727
};
2828

29-
async function loadCaptcha() {
29+
async function loadCaptcha(nonce?: string) {
3030
if (!window.turnstile) {
31-
await loadCaptchaFromCloudflareURL().catch(() => {
31+
await loadCaptchaFromCloudflareURL(nonce).catch(() => {
3232
// eslint-disable-next-line @typescript-eslint/only-throw-error
3333
throw { captchaError: 'captcha_script_failed_to_load' };
3434
});
3535
}
3636
return window.turnstile;
3737
}
3838

39-
async function loadCaptchaFromCloudflareURL() {
39+
async function loadCaptchaFromCloudflareURL(nonce?: string) {
4040
try {
4141
if (__BUILD_DISABLE_RHC__) {
4242
return Promise.reject(new Error('Captcha not supported in this environment'));
4343
}
4444

45-
return await loadScript(CLOUDFLARE_TURNSTILE_ORIGINAL_URL, { defer: true });
45+
return await loadScript(CLOUDFLARE_TURNSTILE_ORIGINAL_URL, { defer: true, nonce });
4646
} catch (err) {
4747
console.warn(
4848
'Clerk: Failed to load the CAPTCHA script from Cloudflare. If you see a CSP error in your browser, please add the necessary CSP rules to your app. Visit https://clerk.com/docs/security/clerk-csp for more information.',
@@ -71,9 +71,9 @@ function getCaptchaAttibutesFromElemenet(element: HTMLElement): CaptchaAttribute
7171
* not exist, the invisibleSiteKey is used as a fallback and the widget is rendered in a hidden div at the bottom of the body.
7272
*/
7373
export const getTurnstileToken = async (opts: CaptchaOptions) => {
74-
const { siteKey, widgetType, invisibleSiteKey } = opts;
74+
const { siteKey, widgetType, invisibleSiteKey, nonce } = opts;
7575
const { modalContainerQuerySelector, modalWrapperQuerySelector, closeModal, openModal } = opts;
76-
const captcha: Turnstile.Turnstile = await loadCaptcha();
76+
const captcha: Turnstile.Turnstile = await loadCaptcha(nonce);
7777
const errorCodes: (string | number)[] = [];
7878

7979
let captchaToken = '';

packages/clerk-js/src/utils/captcha/types.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import type { CaptchaProvider, CaptchaWidgetType } from '@clerk/types';
22

33
export type CaptchaOptions = {
4-
siteKey: string;
5-
widgetType: CaptchaWidgetType;
6-
invisibleSiteKey: string;
4+
action?: 'verify' | 'signup' | 'heartbeat';
75
captchaProvider: CaptchaProvider;
6+
closeModal?: () => Promise<unknown>;
7+
invisibleSiteKey: string;
88
modalContainerQuerySelector?: string;
99
modalWrapperQuerySelector?: string;
10+
nonce?: string;
1011
openModal?: () => Promise<unknown>;
11-
closeModal?: () => Promise<unknown>;
12-
action?: 'verify' | 'signup' | 'heartbeat';
12+
siteKey: string;
13+
widgetType: CaptchaWidgetType;
1314
};
1415

1516
export type GetCaptchaTokenReturn = {

0 commit comments

Comments
 (0)