Skip to content

Commit f467cc3

Browse files
brianhallmadblex
andauthored
Add Image Captcha Solver (#1612)
* First pass - image captcha * Add SVG converter * Feature complete * Add docs * Types * Fix up types * Fix up linting issues * Lint fixes * Minor clean-up * Add tests * Lint fix * Fix netlify * Revert netlify fix * Code Review Feedback * Add type checks for input * fix: exports issue * Fix else statement * Group success conditions --------- Co-authored-by: Alex <[email protected]>
1 parent 225153b commit f467cc3

File tree

17 files changed

+399
-29
lines changed

17 files changed

+399
-29
lines changed

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { test as base } from '@playwright/test';
22
import { createConfiguredDbpTest } from './fixtures';
3-
import { createGetRecaptchaInfoAction, createSolveRecaptchaAction } from '../mocks/broker-protection/captcha.js';
3+
import {
4+
createGetRecaptchaInfoAction,
5+
createSolveRecaptchaAction,
6+
createGetImageCaptchaInfoAction,
7+
createSolveImageCaptchaAction,
8+
} from '../mocks/broker-protection/captcha.js';
49
import { BROKER_PROTECTION_CONFIGS } from './tests-config.js';
510

611
const test = createConfiguredDbpTest(base);
@@ -99,4 +104,38 @@ test.describe('Broker Protection Captcha', () => {
99104
dbp.isQueryParamRemoved(sucessResponse);
100105
});
101106
});
107+
108+
test.describe('image captcha', () => {
109+
const imageCaptchaTargetPage = 'image-captcha.html';
110+
const imageCaptchaResponseSelector = '#svgCaptchaInputId';
111+
112+
test.describe('getCaptchaInfo', () => {
113+
test('returns the expected response for the correct action data', async ({ createConfiguredDbp }) => {
114+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
115+
await dbp.navigatesTo(imageCaptchaTargetPage);
116+
await dbp.receivesInlineAction(createGetImageCaptchaInfoAction({ selector: '#svg-captcha-rendering svg' }));
117+
const sucessResponse = await dbp.getSuccessResponse();
118+
dbp.isCaptchaMatch(sucessResponse, { captchaType: 'image', targetPage: imageCaptchaTargetPage });
119+
});
120+
121+
test('returns an error response when the selector is not an svg or image tag', async ({ createConfiguredDbp }) => {
122+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
123+
await dbp.navigatesTo(imageCaptchaTargetPage);
124+
await dbp.receivesInlineAction(createGetRecaptchaInfoAction({ selector: '#svg-captcha-rendering' }));
125+
126+
await dbp.isCaptchaError();
127+
});
128+
});
129+
130+
test.describe('solveCaptchaInfo', () => {
131+
test('solves the captcha for the correct action data', async ({ createConfiguredDbp }) => {
132+
const dbp = await createConfiguredDbp(BROKER_PROTECTION_CONFIGS.default);
133+
await dbp.navigatesTo(imageCaptchaTargetPage);
134+
await dbp.receivesInlineAction(createSolveImageCaptchaAction({ selector: imageCaptchaResponseSelector }));
135+
dbp.getSuccessResponse();
136+
137+
await dbp.isCaptchaTokenFilled(imageCaptchaResponseSelector);
138+
});
139+
});
140+
});
102141
});

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ export function createGetRecaptchaInfoAction(actionOverrides = {}) {
3434
});
3535
}
3636

37+
/**
38+
* @param {Partial<PirAction>} [actionOverrides]
39+
*/
40+
export function createGetImageCaptchaInfoAction(actionOverrides = {}) {
41+
return createGetCaptchaInfoAction({
42+
action: {
43+
captchaType: 'image',
44+
...actionOverrides,
45+
},
46+
});
47+
}
48+
3749
/**
3850
* @param {object} params
3951
* @param {Omit<PirAction, 'id' | 'actionType'>} params.action
@@ -66,6 +78,18 @@ export function createSolveRecaptchaAction(actionOverrides = {}) {
6678
});
6779
}
6880

81+
/**
82+
* @param {Partial<PirAction>} [actionOverrides]
83+
*/
84+
export function createSolveImageCaptchaAction(actionOverrides = {}) {
85+
return createSolveCaptchaAction({
86+
action: {
87+
captchaType: 'image',
88+
...actionOverrides,
89+
},
90+
});
91+
}
92+
6993
// Captcha responses
7094

7195
/**

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export class BrokerProtectionPage {
7070
* @return {Promise<void>}
7171
*/
7272
async isCaptchaTokenFilled(responseElementSelector) {
73-
const captchaTextArea = await this.page.$(responseElementSelector);
74-
const captchaToken = await captchaTextArea?.evaluate((element) => element.innerHTML);
73+
const captchaTarget = await this.page.$(responseElementSelector);
74+
const captchaToken = await captchaTarget?.evaluate((element) => ('value' in element ? element.value : element.innerHTML));
7575
expect(captchaToken).toBe('test_token');
7676
}
7777

@@ -99,7 +99,17 @@ export class BrokerProtectionPage {
9999
*/
100100
isCaptchaMatch(response, { captchaType, targetPage }) {
101101
const expectedResponse = createCaptchaResponse({ captchaType, targetPage });
102-
expect(response).toStrictEqual(expectedResponse);
102+
103+
switch (captchaType) {
104+
case 'image':
105+
// Validate that the correct keys are present in the response
106+
expect(Object.keys(response).sort()).toStrictEqual(Object.keys(expectedResponse).sort());
107+
// Validate that the siteKey looks like a base64 encoded image
108+
expect(response.siteKey).toMatch(/^data:image\/jpeg;base64,/);
109+
break;
110+
default:
111+
expect(response).toStrictEqual(expectedResponse);
112+
}
103113
}
104114

105115
async isCaptchaError() {

injected/integration-test/test-pages/broker-protection/pages/image-captcha.html

Lines changed: 66 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export { extract } from './extract';
2-
export { fillForm } from './fill-form';
3-
export { click } from './click';
4-
export { expectation } from './expectation';
5-
export { navigate } from './navigate';
6-
export { getCaptchaInfo, solveCaptcha } from '../captcha-services/captcha.service';
1+
export { extract } from './extract.js';
2+
export { fillForm } from './fill-form.js';
3+
export { click } from './click.js';
4+
export { expectation } from './expectation.js';
5+
export { navigate } from './navigate.js';
6+
export { getCaptchaInfo, solveCaptcha } from '../captcha-services/captcha.service.js';

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export function getSupportingCodeToInject(action) {
5151
*
5252
* @param {import('../types.js').PirAction} action
5353
* @param {Document | HTMLElement} root
54-
* @return {import('../types.js').ActionResponse}
54+
* @return {Promise<import('../types.js').ActionResponse>}
5555
*/
56-
export function getCaptchaInfo(action, root = document) {
56+
export async function getCaptchaInfo(action, root = document) {
5757
const { id: actionID, actionType, captchaType, selector } = action;
5858
if (!captchaType) {
5959
// ensures backward compatibility with old actions
@@ -72,7 +72,7 @@ export function getCaptchaInfo(action, root = document) {
7272
return createError(captchaProvider.error.message);
7373
}
7474

75-
const captchaIdentifier = captchaProvider.getCaptchaIdentifier(captchaContainer);
75+
const captchaIdentifier = await captchaProvider.getCaptchaIdentifier(captchaContainer);
7676
if (!captchaIdentifier) {
7777
return createError(`could not extract captcha identifier from the container with selector ${selector}`);
7878
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class CloudFlareTurnstileProvider {
2222
*/
2323
getCaptchaIdentifier(_captchaContainerElement) {
2424
// TODO: Implement
25-
return null;
25+
return Promise.resolve(null);
2626
}
2727

2828
getSupportingCodeToInject() {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class HCaptchaProvider {
2222
*/
2323
getCaptchaIdentifier(_captchaContainerElement) {
2424
// TODO: Implement
25-
return null;
25+
return Promise.resolve(null);
2626
}
2727

2828
getSupportingCodeToInject() {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { PirError } from '../../types';
2+
import { svgToBase64Jpg, imageToBase64 } from '../utils/image';
3+
import { injectTokenIntoElement } from '../utils/token';
4+
import { isElementType } from '../utils/element';
5+
import { stringifyFunction } from '../utils/stringify-function';
6+
7+
/**
8+
* @import { CaptchaProvider } from './provider.interface';
9+
* @implements {CaptchaProvider}
10+
*/
11+
export class ImageProvider {
12+
getType() {
13+
return 'image';
14+
}
15+
16+
/**
17+
* @param {HTMLElement} captchaImageElement - The captcha image element
18+
*/
19+
isSupportedForElement(captchaImageElement) {
20+
if (!captchaImageElement) {
21+
return false;
22+
}
23+
24+
return isElementType(captchaImageElement, ['img', 'svg']);
25+
}
26+
27+
/**
28+
* @param {HTMLElement} captchaImageElement - The captcha image element
29+
*/
30+
async getCaptchaIdentifier(captchaImageElement) {
31+
if (isSVGElement(captchaImageElement)) {
32+
return await svgToBase64Jpg(captchaImageElement);
33+
}
34+
35+
if (isImgElement(captchaImageElement)) {
36+
return imageToBase64(captchaImageElement);
37+
}
38+
39+
return PirError.create(
40+
`[ImageProvider.getCaptchaIdentifier] could not extract Base64 from image with tag name: ${captchaImageElement.tagName}`,
41+
);
42+
}
43+
44+
getSupportingCodeToInject() {
45+
return null;
46+
}
47+
48+
/**
49+
* @param {HTMLElement} captchaInputElement - The captcha input element
50+
*/
51+
canSolve(captchaInputElement) {
52+
return isElementType(captchaInputElement, ['input', 'textarea']);
53+
}
54+
55+
/**
56+
* @param {HTMLInputElement} captchaInputElement - The captcha input element
57+
* @param {string} token - The solved captcha token
58+
*/
59+
injectToken(captchaInputElement, token) {
60+
return injectTokenIntoElement({ captchaInputElement, token });
61+
}
62+
63+
/**
64+
* @param {HTMLElement} _captchaInputElement - The element containing the captcha
65+
* @param {string} _token - The solved captcha token
66+
*/
67+
getSolveCallback(_captchaInputElement, _token) {
68+
return stringifyFunction({
69+
functionBody: function callbackNoop() {},
70+
functionName: 'callbackNoop',
71+
args: {},
72+
});
73+
}
74+
}
75+
76+
/**
77+
* @param {HTMLElement} element
78+
* @return {element is SVGElement}
79+
*/
80+
function isSVGElement(element) {
81+
return isElementType(element, 'svg');
82+
}
83+
84+
/**
85+
* @param {HTMLElement} element
86+
* @return {element is HTMLImageElement}
87+
*/
88+
function isImgElement(element) {
89+
return isElementType(element, 'img');
90+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ export class CaptchaProvider {
2828
* Extracts the site key from the captcha container element
2929
* @abstract
3030
* @param {HTMLElement} _captchaContainerElement - The element containing the captcha
31-
* @returns {PirError | string | null} The site key or null if not found
31+
* @returns {Promise<PirError | string | null>} The site key or null if not found
3232
*/
3333
getCaptchaIdentifier(_captchaContainerElement) {
34-
throw new Error('getCaptchaIdentifier() missing implementation');
34+
return Promise.reject(new Error('getCaptchaIdentifier() missing implementation'));
3535
}
3636

3737
/**

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ export class ReCaptchaProvider {
4646
* @param {HTMLElement} captchaContainerElement
4747
*/
4848
getCaptchaIdentifier(captchaContainerElement) {
49-
return safeCallWithError(
50-
() => getSiteKeyFromSearchParam({ captchaElement: this._getCaptchaElement(captchaContainerElement), siteKeyAttrName: 'k' }),
51-
{ errorMessage: '[ReCaptchaProvider.getCaptchaIdentifier] could not extract site key' },
49+
return Promise.resolve(
50+
safeCallWithError(
51+
() => getSiteKeyFromSearchParam({ captchaElement: this._getCaptchaElement(captchaContainerElement), siteKeyAttrName: 'k' }),
52+
{ errorMessage: '[ReCaptchaProvider.getCaptchaIdentifier] could not extract site key' },
53+
),
5254
);
5355
}
5456

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CaptchaFactory } from '../factory';
22
import { ReCaptchaProvider } from './recaptcha';
3+
import { ImageProvider } from './image';
34

45
const captchaFactory = new CaptchaFactory();
56

@@ -19,4 +20,6 @@ captchaFactory.registerProvider(
1920
}),
2021
);
2122

23+
captchaFactory.registerProvider(new ImageProvider());
24+
2225
export { captchaFactory };
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
*
3+
* @param {HTMLElement} element - The element to check
4+
* @param {string | string[]} tag - The tag name(s) to check against
5+
* @returns {boolean} - True if the element is of the specified tag name(s), false otherwise
6+
*/
7+
export function isElementType(element, tag) {
8+
if (Array.isArray(tag)) {
9+
return tag.some((t) => isElementType(element, t));
10+
}
11+
12+
return element.tagName.toLowerCase() === tag.toLowerCase();
13+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Converts an SVG element to a base64-encoded JPEG string.
3+
*
4+
* @param {SVGElement} svgElement - The SVG element to convert
5+
* @param {string} [backgroundColor='white'] - The background color for the JPEG image
6+
* @return {Promise<string>} - A promise that resolves to the base64-encoded JPEG image
7+
*/
8+
export function svgToBase64Jpg(svgElement, backgroundColor = 'white') {
9+
const svgString = new XMLSerializer().serializeToString(svgElement);
10+
const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(svgString);
11+
12+
return new Promise((resolve, reject) => {
13+
const img = new Image();
14+
img.onload = () => {
15+
const canvas = document.createElement('canvas');
16+
const ctx = canvas.getContext('2d');
17+
18+
if (!ctx) {
19+
reject(new Error('Could not get 2D context from canvas'));
20+
return;
21+
}
22+
23+
canvas.width = img.width;
24+
canvas.height = img.height;
25+
26+
ctx.fillStyle = backgroundColor;
27+
ctx.fillRect(0, 0, canvas.width, canvas.height);
28+
ctx.drawImage(img, 0, 0);
29+
30+
const jpgBase64 = canvas.toDataURL('image/jpeg');
31+
32+
resolve(jpgBase64);
33+
};
34+
img.onerror = (error) => {
35+
reject(error);
36+
};
37+
38+
img.src = svgDataUrl;
39+
});
40+
}
41+
42+
/**
43+
* Converts an image element to a base64-encoded JPEG string
44+
*
45+
* @param {HTMLImageElement} imageElement - The image element to convert.
46+
* @return {string} - The base64-encoded JPEG string.
47+
* @throws {Error} - Throws an error if the canvas context cannot be obtained.
48+
*/
49+
export function imageToBase64(imageElement) {
50+
const canvas = document.createElement('canvas');
51+
const ctx = canvas.getContext('2d');
52+
53+
if (!ctx) {
54+
throw Error('[imageToBase64] Could not get 2D context from canvas');
55+
}
56+
57+
canvas.width = imageElement.width;
58+
canvas.height = imageElement.height;
59+
60+
ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height);
61+
62+
const base64String = canvas.toDataURL('image/jpeg'); // You can change the format if needed
63+
64+
return base64String;
65+
}

0 commit comments

Comments
 (0)