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
10 changes: 10 additions & 0 deletions src/chrome/src/agent/captcha-frame-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ function candidateSummary(candidate) {
recaptchaDataSValue: candidate?.recaptchaDataSValue || null,
explicitWebsiteKey: candidate?.explicitWebsiteKey === true,
callbackName: candidate?.callbackName || null,
responseTokenPresent: candidate?.responseTokenPresent === true,
responseFieldId: candidate?.responseFieldId || null,
responseFieldIndex: Number.isInteger(candidate?.responseFieldIndex)
? candidate.responseFieldIndex
Expand Down Expand Up @@ -407,6 +408,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
responseField: previous.responseField === true || candidate.responseField === true,
responseTokenPresent: previous.responseTokenPresent === true
|| candidate.responseTokenPresent === true,
};
for (const field of taskParameterFields) {
const previousValue = previous[field];
Expand Down Expand Up @@ -686,13 +689,16 @@ export function detectCaptchaCandidatesInPage(scope = null) {
const {
responseFieldDialogAssociated,
alsoResponseFieldDialogAssociated,
alsoResponseTokenPresent,
...serializableCandidate
} = candidate;
candidates.push({
...serializableCandidate,
frameUrl,
challengeFrame,
responseField,
responseTokenPresent: candidate.responseTokenPresent === true
|| alsoResponseTokenPresent === true,
documentTimeOrigin,
dialogAssociated: candidate.dialogAssociated === true
|| responseFieldDialogAssociated === true
Expand Down Expand Up @@ -728,9 +734,12 @@ export function detectCaptchaCandidatesInPage(scope = null) {
const responseFieldIndex = fields.indexOf(field);
let responseFieldId = '';
try { responseFieldId = String(field.id || field.getAttribute?.('id') || ''); } catch (_) {}
let responseTokenPresent = false;
try { responseTokenPresent = String(field.value || '').trim().length > 0; } catch (_) {}
return {
...(responseFieldId ? { responseFieldId } : {}),
...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}),
responseTokenPresent,
...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}),
};
};
Expand All @@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
...(Number.isInteger(identity.responseFieldIndex)
? { alsoResponseFieldIndex: identity.responseFieldIndex }
: {}),
alsoResponseTokenPresent: identity.responseTokenPresent === true,
...(identity.responseFieldDialogAssociated
? { alsoResponseFieldDialogAssociated: true }
: {}),
Expand Down
10 changes: 10 additions & 0 deletions src/firefox/src/agent/captcha-frame-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ function candidateSummary(candidate) {
recaptchaDataSValue: candidate?.recaptchaDataSValue || null,
explicitWebsiteKey: candidate?.explicitWebsiteKey === true,
callbackName: candidate?.callbackName || null,
responseTokenPresent: candidate?.responseTokenPresent === true,
responseFieldId: candidate?.responseFieldId || null,
responseFieldIndex: Number.isInteger(candidate?.responseFieldIndex)
? candidate.responseFieldIndex
Expand Down Expand Up @@ -407,6 +408,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
responseField: previous.responseField === true || candidate.responseField === true,
responseTokenPresent: previous.responseTokenPresent === true
|| candidate.responseTokenPresent === true,
};
for (const field of taskParameterFields) {
const previousValue = previous[field];
Expand Down Expand Up @@ -686,13 +689,16 @@ export function detectCaptchaCandidatesInPage(scope = null) {
const {
responseFieldDialogAssociated,
alsoResponseFieldDialogAssociated,
alsoResponseTokenPresent,
...serializableCandidate
} = candidate;
candidates.push({
...serializableCandidate,
frameUrl,
challengeFrame,
responseField,
responseTokenPresent: candidate.responseTokenPresent === true
|| alsoResponseTokenPresent === true,
documentTimeOrigin,
dialogAssociated: candidate.dialogAssociated === true
|| responseFieldDialogAssociated === true
Expand Down Expand Up @@ -728,9 +734,12 @@ export function detectCaptchaCandidatesInPage(scope = null) {
const responseFieldIndex = fields.indexOf(field);
let responseFieldId = '';
try { responseFieldId = String(field.id || field.getAttribute?.('id') || ''); } catch (_) {}
let responseTokenPresent = false;
try { responseTokenPresent = String(field.value || '').trim().length > 0; } catch (_) {}
return {
...(responseFieldId ? { responseFieldId } : {}),
...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}),
responseTokenPresent,
...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}),
};
};
Expand All @@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
...(Number.isInteger(identity.responseFieldIndex)
? { alsoResponseFieldIndex: identity.responseFieldIndex }
: {}),
alsoResponseTokenPresent: identity.responseTokenPresent === true,
...(identity.responseFieldDialogAssociated
? { alsoResponseFieldDialogAssociated: true }
: {}),
Expand Down
119 changes: 118 additions & 1 deletion test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -52554,6 +52554,7 @@ function captchaEl(tag, attrs = {}, children = []) {
src: attrs.src,
innerText: attrs.innerText || '',
textContent: attrs.textContent || attrs.innerText || '',
value: attrs.value || '',
hidden: attrs.hidden === true,
style: {},
classList: { contains: (c) => String(attrs.class || '').split(/\s+/).includes(c) },
Expand Down Expand Up @@ -52685,6 +52686,13 @@ async function detectCaptchaOnFakePage(build, nodes) {
});
}

async function detectCaptchaDetailsOnFakePage(build, nodes) {
return withCaptchaFakePage(build, nodes, async () => {
const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href);
return mod.detectCaptcha(1);
});
}

test('challenge-dialog routing detects supported widgets and diagnoses unsupported Arkose frames', async () => {
for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) {
const supportedNodes = [
Expand Down Expand Up @@ -53136,12 +53144,17 @@ test('challenge dialog with no enabled supported solver stops the batch for manu
}
});

test('CAPTCHA gate helper stays byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
test('CAPTCHA helpers stay byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
assert.equal(
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-gate.js'), 'utf8'),
fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-gate.js'), 'utf8'),
'chrome and firefox CAPTCHA gate helpers must remain byte-identical',
);
assert.equal(
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-frame-runtime.js'), 'utf8'),
fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-frame-runtime.js'), 'utf8'),
'chrome and firefox CAPTCHA frame runtimes must remain byte-identical',
);
const cloudSource = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8');
assert.match(cloudSource, /if \(type === 'captcha_gate'\)[\s\S]*?run\.captchaDiagnostics = \{ \.\.\.scrubbedData, observedAt: run\.updatedAt \};/);
assert.match(cloudSource, /\.\.\.\(run\.captchaDiagnostics \? \{ captchaDiagnostics: run\.captchaDiagnostics \} : \{\}\)/);
Expand Down Expand Up @@ -53401,6 +53414,110 @@ test('captcha detection binds the selected widget to its own response field', as
}
});

test('captcha detection reports exact response token state without exposing token values', async () => {
const token = 'RAW_CAPTCHA_TOKEN_MUST_NOT_ESCAPE_7fc2';
for (const build of ['chrome', 'firefox']) {
const solvedField = captchaEl('textarea', {
id: 'g-recaptcha-response-solved',
name: 'g-recaptcha-response',
value: token,
});
const pendingField = captchaEl('textarea', {
id: 'g-recaptcha-response-pending',
name: 'g-recaptcha-response',
});
const solvedHost = captchaEl('div', {
class: 'g-recaptcha',
'data-sitekey': 'KEY_SOLVED_WIDGET',
hidden: true,
}, [solvedField]);
const pendingHost = captchaEl('div', {
class: 'g-recaptcha',
'data-sitekey': 'KEY_PENDING_WIDGET',
}, [pendingField]);

const pendingDetection = await detectCaptchaDetailsOnFakePage(build, [
solvedHost,
pendingHost,
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
]);
assert.equal(
pendingDetection.selected.responseTokenPresent,
false,
`${build}: a solved sibling widget contaminated the selected pending widget`,
);
assert.equal(
pendingDetection.candidates.find(candidate =>
candidate.websiteKey === 'KEY_SOLVED_WIDGET'
)?.responseTokenPresent,
true,
`${build}: non-empty exact response field was not reported as solved`,
);
assert.doesNotMatch(
JSON.stringify(pendingDetection),
new RegExp(token),
`${build}: raw response token escaped through detection`,
);

pendingField.value = token;
const solvedDetection = await detectCaptchaDetailsOnFakePage(build, [
solvedHost,
pendingHost,
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
]);
assert.equal(
solvedDetection.selected.responseTokenPresent,
true,
`${build}: selected non-empty response field was not reported as solved`,
);
assert.doesNotMatch(
JSON.stringify(solvedDetection),
new RegExp(token),
`${build}: selected raw response token escaped through detection`,
);

const primaryField = captchaEl('textarea', {
id: 'h-captcha-response-primary',
name: 'h-captcha-response',
});
const compatibilityField = captchaEl('textarea', {
id: 'g-recaptcha-response-compatibility',
name: 'g-recaptcha-response',
value: token,
});
const hcaptcha = await detectCaptchaOnFakePage(build, [
captchaEl('div', {
class: 'h-captcha',
'data-sitekey': 'HKEY_SOLVED_COMPATIBILITY',
}, [primaryField, compatibilityField]),
]);
assert.equal(
hcaptcha.responseTokenPresent,
true,
`${build}: solved hCaptcha compatibility field was ignored`,
);
assert.doesNotMatch(
JSON.stringify(hcaptcha),
new RegExp(token),
`${build}: hCaptcha compatibility token escaped through detection`,
);

Object.defineProperty(pendingField, 'value', {
configurable: true,
get() { throw new Error('hostile response field getter'); },
});
const unreadable = await detectCaptchaOnFakePage(build, [
pendingHost,
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
]);
assert.equal(
unreadable.responseTokenPresent,
false,
`${build}: unreadable response field did not fail closed`,
);
}
});

test('captcha detection ranks a visible nested v2 Enterprise challenge above background v3', async () => {
const topCandidate = {
type: 'recaptcha_v3_enterprise',
Expand Down
Loading