Skip to content

Commit c621d15

Browse files
committed
harden captcha response field injection
1 parent 0805e56 commit c621d15

3 files changed

Lines changed: 94 additions & 9 deletions

File tree

src/chrome/src/agent/captcha-frame-runtime.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10461046
}
10471047
});
10481048
if (exact) return { element: exact };
1049+
return {
1050+
error: 'The selected CAPTCHA response field ID is no longer present in the target frame.',
1051+
staleTarget: true,
1052+
};
10491053
}
10501054
if (Number.isInteger(selectedFieldIndex)) {
10511055
if (fields[selectedFieldIndex]) {
@@ -1073,7 +1077,7 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10731077
...field,
10741078
...resolveResponseField(field.name, field.primary),
10751079
}));
1076-
const unresolvedField = resolvedFields.find(field => field.error);
1080+
const unresolvedField = resolvedFields.find(field => field.primary && field.error);
10771081
if (unresolvedField) {
10781082
return {
10791083
success: false,
@@ -1084,6 +1088,8 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10841088
frameUrl,
10851089
};
10861090
}
1091+
const skippedFields = resolvedFields.filter(field => !field.primary && field.error);
1092+
const injectableFields = resolvedFields.filter(field => !field.error);
10871093

10881094
const setOn = (name, existingElement) => {
10891095
let element = existingElement;
@@ -1116,10 +1122,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
11161122
}
11171123
return element;
11181124
};
1119-
for (const field of resolvedFields) {
1125+
for (const field of injectableFields) {
11201126
setOn(field.name, field.element);
11211127
}
1122-
const fieldsTouched = resolvedFields.length;
1128+
const fieldsTouched = injectableFields.length;
11231129

11241130
const pageWindow = frameWindow.wrappedJSObject || frameWindow;
11251131
const callbacks = [];
@@ -1204,8 +1210,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
12041210
return {
12051211
success: true,
12061212
fieldUpdated: true,
1207-
fieldsUpdated: [fieldName, ...(alsoSet ? [alsoSet] : [])],
1213+
fieldsUpdated: injectableFields.map(field => field.name),
12081214
fieldsTouched,
1215+
compatibilityFieldSkipped: skippedFields.length > 0,
1216+
compatibilityFieldError: skippedFields[0]?.error || null,
12091217
responseFieldId: target.responseFieldId || null,
12101218
responseFieldIndex: Number.isInteger(target.responseFieldIndex)
12111219
? target.responseFieldIndex

src/firefox/src/agent/captcha-frame-runtime.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10461046
}
10471047
});
10481048
if (exact) return { element: exact };
1049+
return {
1050+
error: 'The selected CAPTCHA response field ID is no longer present in the target frame.',
1051+
staleTarget: true,
1052+
};
10491053
}
10501054
if (Number.isInteger(selectedFieldIndex)) {
10511055
if (fields[selectedFieldIndex]) {
@@ -1073,7 +1077,7 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10731077
...field,
10741078
...resolveResponseField(field.name, field.primary),
10751079
}));
1076-
const unresolvedField = resolvedFields.find(field => field.error);
1080+
const unresolvedField = resolvedFields.find(field => field.primary && field.error);
10771081
if (unresolvedField) {
10781082
return {
10791083
success: false,
@@ -1084,6 +1088,8 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
10841088
frameUrl,
10851089
};
10861090
}
1091+
const skippedFields = resolvedFields.filter(field => !field.primary && field.error);
1092+
const injectableFields = resolvedFields.filter(field => !field.error);
10871093

10881094
const setOn = (name, existingElement) => {
10891095
let element = existingElement;
@@ -1116,10 +1122,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
11161122
}
11171123
return element;
11181124
};
1119-
for (const field of resolvedFields) {
1125+
for (const field of injectableFields) {
11201126
setOn(field.name, field.element);
11211127
}
1122-
const fieldsTouched = resolvedFields.length;
1128+
const fieldsTouched = injectableFields.length;
11231129

11241130
const pageWindow = frameWindow.wrappedJSObject || frameWindow;
11251131
const callbacks = [];
@@ -1204,8 +1210,10 @@ export function injectCaptchaTokenInPage(payload, scope = null) {
12041210
return {
12051211
success: true,
12061212
fieldUpdated: true,
1207-
fieldsUpdated: [fieldName, ...(alsoSet ? [alsoSet] : [])],
1213+
fieldsUpdated: injectableFields.map(field => field.name),
12081214
fieldsTouched,
1215+
compatibilityFieldSkipped: skippedFields.length > 0,
1216+
compatibilityFieldError: skippedFields[0]?.error || null,
12091217
responseFieldId: target.responseFieldId || null,
12101218
responseFieldIndex: Number.isInteger(target.responseFieldIndex)
12111219
? target.responseFieldIndex

test/run.js

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52148,16 +52148,35 @@ test('captcha token injection revalidates the selected frame and calls one match
5214852148
assert.equal(hiddenCallbackToken, null, `${build}: hidden widget callback was invoked`);
5214952149
assert.equal(modalCallbackToken, 'TOKEN_MODAL_WIDGET', `${build}: selected callback was not invoked`);
5215052150

52151+
hiddenResponse.value = '';
52152+
modalResponse.value = '';
52153+
const staleFieldId = runtime.injectCaptchaTokenInPage({
52154+
fieldName: 'g-recaptcha-response',
52155+
token: 'TOKEN_STALE_FIELD_ID',
52156+
target: {
52157+
frameId: 9,
52158+
frameUrl: globalThis.location.href,
52159+
websiteKey: 'KEY_SHARED_WIDGET',
52160+
responseFieldId: 'g-recaptcha-response-removed',
52161+
responseFieldIndex: 1,
52162+
},
52163+
});
52164+
assert.equal(staleFieldId.success, false, `${build}: missing selected field ID fell back to an index`);
52165+
assert.equal(staleFieldId.staleTarget, true, `${build}: missing selected field ID was not marked stale`);
52166+
assert.equal(hiddenResponse.value, '', `${build}: stale field ID touched the first response field`);
52167+
assert.equal(modalResponse.value, '', `${build}: stale field ID touched the old indexed response field`);
52168+
5215152169
const canonicalHcaptchaResponse = makeResponse('h-captcha-response-only');
5215252170
let createdCompatibilityResponse = null;
52171+
let hcaptchaCompatibilityResponses = [];
5215352172
const hcaptchaHost = {
5215452173
getAttribute: (name) => name === 'data-sitekey' ? 'HKEY_CANONICAL_ONLY' : null,
5215552174
};
5215652175
const hcaptchaDocument = {
5215752176
querySelector: () => null,
5215852177
querySelectorAll: (selector) => {
5215952178
if (selector.includes('h-captcha-response')) return [canonicalHcaptchaResponse];
52160-
if (selector.includes('g-recaptcha-response')) return [];
52179+
if (selector.includes('g-recaptcha-response')) return hcaptchaCompatibilityResponses;
5216152180
if (selector.includes('[data-sitekey]')) return [hcaptchaHost];
5216252181
return [];
5216352182
},
@@ -52208,6 +52227,56 @@ test('captcha token injection revalidates the selected frame and calls one match
5220852227
`${build}: missing hCaptcha compatibility field was not created`,
5220952228
);
5221052229

52230+
const firstCompatibilityResponse = makeResponse('g-recaptcha-response-first');
52231+
const secondCompatibilityResponse = makeResponse('g-recaptcha-response-second');
52232+
hcaptchaCompatibilityResponses = [firstCompatibilityResponse, secondCompatibilityResponse];
52233+
canonicalHcaptchaResponse.value = '';
52234+
createdCompatibilityResponse = null;
52235+
const ambiguousCompatibilityHcaptcha = runtime.injectCaptchaTokenInPage({
52236+
fieldName: 'h-captcha-response',
52237+
alsoSet: 'g-recaptcha-response',
52238+
token: 'TOKEN_HCAPTCHA_AMBIGUOUS_COMPATIBILITY',
52239+
target: {
52240+
frameId: 9,
52241+
frameUrl: hcaptchaWindow.location.href,
52242+
websiteKey: 'HKEY_CANONICAL_ONLY',
52243+
responseFieldId: 'h-captcha-response-only',
52244+
responseFieldIndex: 0,
52245+
documentTimeOrigin: 123456789,
52246+
},
52247+
}, {
52248+
document: hcaptchaDocument,
52249+
window: hcaptchaWindow,
52250+
});
52251+
assert.equal(
52252+
ambiguousCompatibilityHcaptcha.success,
52253+
true,
52254+
`${build}: ambiguous hCaptcha compatibility fields aborted canonical injection`,
52255+
);
52256+
assert.deepEqual(
52257+
ambiguousCompatibilityHcaptcha.fieldsUpdated,
52258+
['h-captcha-response'],
52259+
`${build}: skipped compatibility field was reported as updated`,
52260+
);
52261+
assert.equal(
52262+
ambiguousCompatibilityHcaptcha.compatibilityFieldSkipped,
52263+
true,
52264+
`${build}: compatibility-field skip was not reported`,
52265+
);
52266+
assert.match(
52267+
ambiguousCompatibilityHcaptcha.compatibilityFieldError,
52268+
/Multiple CAPTCHA response fields/,
52269+
`${build}: compatibility-field ambiguity diagnostic missing`,
52270+
);
52271+
assert.equal(
52272+
canonicalHcaptchaResponse.value,
52273+
'TOKEN_HCAPTCHA_AMBIGUOUS_COMPATIBILITY',
52274+
`${build}: canonical hCaptcha field was not updated after compatibility ambiguity`,
52275+
);
52276+
assert.equal(firstCompatibilityResponse.value, '', `${build}: first ambiguous compatibility field was updated`);
52277+
assert.equal(secondCompatibilityResponse.value, '', `${build}: second ambiguous compatibility field was updated`);
52278+
assert.equal(createdCompatibilityResponse, null, `${build}: ambiguous compatibility field was recreated`);
52279+
5221152280
responseFields = [response];
5221252281
captchaHosts = [host];
5221352282
const selectedFrameUrl = globalThis.location.href;

0 commit comments

Comments
 (0)