Skip to content

Commit bba5ecc

Browse files
authored
Add Cloudflare Turnstile Support (#1633)
* First pass at Cloudflare Turnstile * Fix parameter passing for captcha detection * Prettier * Finishing touches for CloudFlareTurnstile * Minor updates * Bubble up error from captcha provider, use isError instead of direct instance with PirError * Add tests * Make sitekey optional in isCaptchaMatch * Fix accidental deletion * Address PR feedback * Update function call * Trim down cloudflare-captcha.html
1 parent 1929fe3 commit bba5ecc

File tree

14 files changed

+254
-36
lines changed

14 files changed

+254
-36
lines changed

injected/integration-test/broker-protection-tests/broker-protection-captcha.spec.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
createSolveRecaptchaAction,
66
createGetImageCaptchaInfoAction,
77
createSolveImageCaptchaAction,
8+
createGetCloudFlareCaptchaInfoAction,
9+
createSolveCloudFlareCaptchaAction,
810
} from '../mocks/broker-protection/captcha.js';
911
import { BROKER_PROTECTION_CONFIGS } from './tests-config.js';
1012

@@ -121,7 +123,7 @@ test.describe('Broker Protection Captcha', () => {
121123
test('returns an error response when the selector is not an svg or image tag', async ({ createConfiguredDbp }) => {
122124
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
123125
await dbp.navigatesTo(imageCaptchaTargetPage);
124-
await dbp.receivesInlineAction(createGetRecaptchaInfoAction({ selector: '#svg-captcha-rendering' }));
126+
await dbp.receivesInlineAction(createGetImageCaptchaInfoAction({ selector: '#svg-captcha-rendering' }));
125127

126128
await dbp.isCaptchaError();
127129
});
@@ -138,4 +140,50 @@ test.describe('Broker Protection Captcha', () => {
138140
});
139141
});
140142
});
143+
144+
test.describe('cloudflare turnstile', () => {
145+
const cloudFlareCaptchaTargetPage = 'cloudflare-captcha.html';
146+
147+
test.describe('getCaptchaInfo', () => {
148+
test('returns the expected response for the correct action data', async ({ createConfiguredDbp }) => {
149+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
150+
await dbp.navigatesTo(cloudFlareCaptchaTargetPage);
151+
await dbp.receivesInlineAction(createGetCloudFlareCaptchaInfoAction({ selector: '#captcha-widget' }));
152+
const sucessResponse = await dbp.getSuccessResponse();
153+
154+
dbp.isCaptchaMatch(sucessResponse, {
155+
captchaType: 'cloudFlareTurnstile',
156+
targetPage: cloudFlareCaptchaTargetPage,
157+
siteKey: '0x4AAAAAAA34NY6rivjWMWoq',
158+
});
159+
});
160+
161+
test('returns an error if the sitekey attribute is missing', async ({ createConfiguredDbp }) => {
162+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
163+
await dbp.navigatesTo(cloudFlareCaptchaTargetPage);
164+
await dbp.receivesInlineAction(createGetCloudFlareCaptchaInfoAction({ selector: '#missing-sitekey' }));
165+
166+
await dbp.isCaptchaError();
167+
});
168+
});
169+
170+
test.describe('solveCaptchaInfo', () => {
171+
test('returns an error if the callback attribute is missing', async ({ createConfiguredDbp }) => {
172+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
173+
await dbp.navigatesTo(cloudFlareCaptchaTargetPage);
174+
await dbp.receivesInlineAction(createSolveCloudFlareCaptchaAction({ selector: '#missing-callback' }));
175+
176+
await dbp.isCaptchaError();
177+
});
178+
179+
test('solves the captcha for the correct action data', async ({ createConfiguredDbp }) => {
180+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
181+
await dbp.navigatesTo(cloudFlareCaptchaTargetPage);
182+
await dbp.receivesInlineAction(createSolveCloudFlareCaptchaAction({ selector: '#captcha-widget' }));
183+
dbp.getSuccessResponse();
184+
185+
await dbp.isCaptchaTokenFilled("//input[@name='cf-turnstile-response']");
186+
});
187+
});
188+
});
141189
});

injected/integration-test/mocks/broker-protection/captcha.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ export function createGetImageCaptchaInfoAction(actionOverrides = {}) {
4646
});
4747
}
4848

49+
/**
50+
* @param {Partial<PirAction>} [actionOverrides]
51+
*/
52+
export function createGetCloudFlareCaptchaInfoAction(actionOverrides = {}) {
53+
return createGetCaptchaInfoAction({
54+
action: {
55+
captchaType: 'cloudFlareTurnstile',
56+
...actionOverrides,
57+
},
58+
});
59+
}
60+
4961
/**
5062
* @param {object} params
5163
* @param {Omit<PirAction, 'id' | 'actionType'>} params.action
@@ -90,6 +102,18 @@ export function createSolveImageCaptchaAction(actionOverrides = {}) {
90102
});
91103
}
92104

105+
/**
106+
* @param {Partial<PirAction>} [actionOverrides]
107+
*/
108+
export function createSolveCloudFlareCaptchaAction(actionOverrides = {}) {
109+
return createSolveCaptchaAction({
110+
action: {
111+
captchaType: 'cloudFlareTurnstile',
112+
...actionOverrides,
113+
},
114+
});
115+
}
116+
93117
// Captcha responses
94118

95119
/**

injected/integration-test/page-objects/broker-protection.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,12 @@ export class BrokerProtectionPage {
9494
* @param {object} captchaParams
9595
* @param {string} captchaParams.captchaType
9696
* @param {string} captchaParams.targetPage
97+
* @param {string} [captchaParams.siteKey]
9798
*
9899
* @return {void}
99100
*/
100-
isCaptchaMatch(response, { captchaType, targetPage }) {
101-
const expectedResponse = createCaptchaResponse({ captchaType, targetPage });
101+
isCaptchaMatch(response, { captchaType, targetPage, ...overrides }) {
102+
const expectedResponse = createCaptchaResponse({ captchaType, targetPage, ...overrides });
102103

103104
switch (captchaType) {
104105
case 'image':
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<html>
2+
3+
<head>
4+
<title>Beenverified</title>
5+
</head>
6+
7+
<body>
8+
<div id="root">
9+
<div class="MuiBox-root css-0"><input type="hidden" name="captchaResponse">
10+
<div id="captcha-widget" data-sitekey="0x4AAAAAAA34NY6rivjWMWoq"
11+
data-callback="handleCaptchaCallback" style="margin-top: 20px; outline: currentcolor;">
12+
<div><input type="hidden" name="cf-turnstile-response"
13+
id="cf-chl-widget-tzmlk_response"></div>
14+
</div>
15+
</div>
16+
<div class="MuiBox-root css-0"><input type="hidden" name="captchaResponse">
17+
<div id="missing-sitekey" data-callback="handleCaptchaCallback"
18+
style="margin-top: 20px; outline: currentcolor;">
19+
<div><input type="hidden" name="cf-turnstile-response-error"
20+
id="cf-chl-widget-tzmlk_response"></div>
21+
</div>
22+
</div>
23+
<div class="MuiBox-root css-0"><input type="hidden" name="captchaResponse">
24+
<div id="missing-callback" data-sitekey="0x4AAAAAAA34NY6rivjWMWoq"
25+
style="margin-top: 20px; outline: currentcolor;">
26+
<div><input type="hidden" name="cf-turnstile-response-error"
27+
id="cf-chl-widget-tzmlk_response"></div>
28+
</div>
29+
</div>
30+
</div>
31+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async="" data-testid="hcaptcha-script"></script>
32+
</body>
33+
34+
</html>

injected/src/features/broker-protection/captcha-services/captcha.service.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export async function getCaptchaInfo(action, root = document) {
6767
return createError(captchaContainer.error.message);
6868
}
6969

70-
const captchaProvider = getCaptchaProvider(captchaContainer, captchaType);
70+
const captchaProvider = getCaptchaProvider(root, captchaContainer, captchaType);
7171
if (PirError.isError(captchaProvider)) {
7272
return createError(captchaProvider.error.message);
7373
}
@@ -77,6 +77,10 @@ export async function getCaptchaInfo(action, root = document) {
7777
return createError(`could not extract captcha identifier from the container with selector ${selector}`);
7878
}
7979

80+
if (PirError.isError(captchaIdentifier)) {
81+
return createError(captchaIdentifier.error.message);
82+
}
83+
8084
const response = {
8185
url: removeUrlQueryParams(window.location.href), // query params (which may include PII)
8286
siteKey: captchaIdentifier,

injected/src/features/broker-protection/captcha-services/factory.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ export class CaptchaFactory {
2525

2626
/**
2727
* Detect the captcha provider based on the element
28+
* @param {Document | HTMLElement} root
2829
* @param {HTMLElement} element - The element to check
2930
* @returns {import('./providers/provider.interface').CaptchaProvider|null}
3031
*/
31-
detectProvider(element) {
32-
return this._getAllProviders().find((provider) => provider.isSupportedForElement(element)) || null;
32+
detectProvider(root, element) {
33+
return this._getAllProviders().find((provider) => provider.isSupportedForElement(root, element)) || null;
3334
}
3435

3536
/**

injected/src/features/broker-protection/captcha-services/get-captcha-provider.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@ import { captchaFactory } from './providers/registry';
33

44
/**
55
* Gets the captcha provider for the getCaptchaInfo action
6-
*
6+
* @param {Document | HTMLElement} root
77
* @param {HTMLElement} captchaContainer
88
* @param {string} captchaType
99
*/
10-
export function getCaptchaProvider(captchaContainer, captchaType) {
10+
export function getCaptchaProvider(root, captchaContainer, captchaType) {
1111
const captchaProvider = captchaFactory.getProviderByType(captchaType);
1212
if (!captchaProvider) {
1313
return PirError.create(`[getCaptchaProvider] could not find captcha provider with type ${captchaType}`);
1414
}
1515

16-
if (captchaProvider.isSupportedForElement(captchaContainer)) {
16+
if (captchaProvider.isSupportedForElement(root, captchaContainer)) {
1717
return captchaProvider;
1818
}
1919

20-
const detectedProvider = captchaFactory.detectProvider(captchaContainer);
20+
const detectedProvider = captchaFactory.detectProvider(root, captchaContainer);
2121
if (!detectedProvider) {
2222
return PirError.create(
2323
`[getCaptchaProvider] could not detect captcha provider for ${captchaType} captcha and element ${captchaContainer}`,
Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,136 @@
1+
import { getElementByTagName, getElementWithSrcStart } from '../../utils/utils';
2+
import { safeCallWithError } from '../../utils/safe-call';
3+
import { getAttributeValue } from '../utils/attribute';
4+
import { injectTokenIntoElement } from '../utils/token';
5+
import { stringifyFunction } from '../utils/stringify-function';
16
import { PirError } from '../../types';
27

8+
/**
9+
* @typedef {Object} CloudFlareTurnstileProviderConfig
10+
* @property {string} providerUrl - The captcha provider URL
11+
* @property {string} responseElementName - The name of the captcha response element
12+
*/
13+
314
/**
415
* @import { CaptchaProvider } from './provider.interface';
516
* @implements {CaptchaProvider}
617
*/
718
export class CloudFlareTurnstileProvider {
19+
/**
20+
* @type {CloudFlareTurnstileProviderConfig}
21+
*/
22+
#config;
23+
24+
constructor() {
25+
this.#config = {
26+
providerUrl: 'https://challenges.cloudflare.com/turnstile/v0',
27+
responseElementName: 'cf-turnstile-response',
28+
};
29+
}
30+
831
getType() {
9-
return 'cloudflareTurnstile';
32+
return 'cloudFlareTurnstile';
1033
}
1134

1235
/**
36+
* @param {Document | HTMLElement} root
1337
* @param {HTMLElement} _captchaContainerElement
38+
* @returns {boolean} Whether the captcha is supported for the element
1439
*/
15-
isSupportedForElement(_captchaContainerElement) {
16-
// TODO: Implement
17-
return false;
40+
isSupportedForElement(root, _captchaContainerElement) {
41+
// Typically we look within captcha container for isSupportedForElement, but CloudFlare puts the iFrame into the shadow DOM,
42+
// so we need to look at the script tags on the page instead
43+
return !!this._getCaptchaScript(root);
1844
}
1945

2046
/**
21-
* @param {HTMLElement} _captchaContainerElement - The element containing the captcha
47+
* @param {HTMLElement} captchaContainerElement - The element containing the captcha
2248
*/
23-
getCaptchaIdentifier(_captchaContainerElement) {
24-
// TODO: Implement
25-
return Promise.resolve(null);
49+
getCaptchaIdentifier(captchaContainerElement) {
50+
const sitekeyAttribute = 'data-sitekey';
51+
52+
return Promise.resolve(
53+
safeCallWithError(() => getAttributeValue({ element: captchaContainerElement, attrName: sitekeyAttribute }), {
54+
errorMessage: `[CloudFlareTurnstileProvider.getCaptchaIdentifier] could not extract site key from attribute: ${sitekeyAttribute}`,
55+
}),
56+
);
2657
}
2758

2859
getSupportingCodeToInject() {
29-
// TODO: Implement
3060
return null;
3161
}
3262

3363
/**
34-
* @param {HTMLElement} _captchaContainerElement - The element containing the captcha
64+
* @param {HTMLElement} captchaContainerElement - The element containing the captcha
65+
* @returns {boolean} Whether the captcha can be solved
66+
*/
67+
canSolve(captchaContainerElement) {
68+
const callbackAttribute = 'data-callback';
69+
70+
const hasCallback = safeCallWithError(() => getAttributeValue({ element: captchaContainerElement, attrName: callbackAttribute }), {
71+
errorMessage: `[CloudFlareTurnstileProvider.canSolve] could not extract callback function name from attribute: ${callbackAttribute}`,
72+
});
73+
74+
if (PirError.isError(hasCallback)) {
75+
return false;
76+
}
77+
78+
const hasResponseElement = safeCallWithError(() => getElementByTagName(captchaContainerElement, this.#config.responseElementName), {
79+
errorMessage: `[CloudFlareTurnstileProvider.canSolve] could not find response element: ${this.#config.responseElementName}`,
80+
});
81+
82+
if (PirError.isError(hasResponseElement)) {
83+
return false;
84+
}
85+
86+
return true;
87+
}
88+
89+
/**
90+
* @param {HTMLElement} captchaContainerElement - The element containing the captcha
91+
* @param {string} token - The solved captcha token
3592
*/
36-
canSolve(_captchaContainerElement) {
37-
// TODO: Implement
38-
return false;
93+
injectToken(captchaContainerElement, token) {
94+
return injectTokenIntoElement({ captchaContainerElement, elementName: this.#config.responseElementName, token });
3995
}
4096

4197
/**
42-
* @param {HTMLElement} _captchaContainerElement - The element containing the captcha
43-
* @param {string} _token - The solved captcha token
98+
* @param {HTMLElement} captchaContainerElement - The element containing the captcha
99+
* @param {string} token - The solved captcha token
44100
*/
45-
injectToken(_captchaContainerElement, _token) {
46-
// TODO: Implement
47-
return PirError.create('Not implemented');
101+
getSolveCallback(captchaContainerElement, token) {
102+
const callbackAttribute = 'data-callback';
103+
104+
const callbackFunctionName = safeCallWithError(
105+
() => getAttributeValue({ element: captchaContainerElement, attrName: callbackAttribute }),
106+
{
107+
errorMessage: `[CloudFlareTurnstileProvider.getSolveCallback] could not extract callback function name from attribute: ${callbackAttribute}`,
108+
},
109+
);
110+
111+
if (PirError.isError(callbackFunctionName)) {
112+
return callbackFunctionName;
113+
}
114+
115+
return stringifyFunction({
116+
/**
117+
* @param {Object} args - The arguments passed to the function
118+
* @param {string} args.callbackFunctionName - The callback function name
119+
* @param {string} args.token - The solved captcha token
120+
*/
121+
functionBody: function cloudflareCaptchaCallback(args) {
122+
window[args.callbackFunctionName](args.token);
123+
},
124+
functionName: 'cloudflareCaptchaCallback',
125+
args: { callbackFunctionName, token },
126+
});
48127
}
49128

50129
/**
51-
* @param {HTMLElement} _captchaContainerElement - The element containing the captcha
52-
* @param {string} _token - The solved captcha token
130+
* @private
131+
* @param {Document | HTMLElement} root - The root element to search in
53132
*/
54-
getSolveCallback(_captchaContainerElement, _token) {
55-
return null;
133+
_getCaptchaScript(root) {
134+
return getElementWithSrcStart(root, this.#config.providerUrl);
56135
}
57136
}

injected/src/features/broker-protection/captcha-services/providers/hcaptcha.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ export class HCaptchaProvider {
1010
}
1111

1212
/**
13+
* @param {Document | HTMLElement} _root
1314
* @param {HTMLElement} _captchaContainerElement - The element to check
1415
*/
15-
isSupportedForElement(_captchaContainerElement) {
16+
isSupportedForElement(_root, _captchaContainerElement) {
1617
// TODO: Implement
1718
return false;
1819
}

0 commit comments

Comments
 (0)