Skip to content

Commit ce408ea

Browse files
authored
Merge pull request #512 from alectimison-maker/fix/captcha-response-token-state
fix(captcha): report response token state
2 parents 0591756 + 7e9cf42 commit ce408ea

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ function candidateSummary(candidate) {
300300
recaptchaDataSValue: candidate?.recaptchaDataSValue || null,
301301
explicitWebsiteKey: candidate?.explicitWebsiteKey === true,
302302
callbackName: candidate?.callbackName || null,
303+
responseTokenPresent: candidate?.responseTokenPresent === true,
303304
responseFieldId: candidate?.responseFieldId || null,
304305
responseFieldIndex: Number.isInteger(candidate?.responseFieldIndex)
305306
? candidate.responseFieldIndex
@@ -407,6 +408,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
407408
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
408409
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
409410
responseField: previous.responseField === true || candidate.responseField === true,
411+
responseTokenPresent: previous.responseTokenPresent === true
412+
|| candidate.responseTokenPresent === true,
410413
};
411414
for (const field of taskParameterFields) {
412415
const previousValue = previous[field];
@@ -686,13 +689,16 @@ export function detectCaptchaCandidatesInPage(scope = null) {
686689
const {
687690
responseFieldDialogAssociated,
688691
alsoResponseFieldDialogAssociated,
692+
alsoResponseTokenPresent,
689693
...serializableCandidate
690694
} = candidate;
691695
candidates.push({
692696
...serializableCandidate,
693697
frameUrl,
694698
challengeFrame,
695699
responseField,
700+
responseTokenPresent: candidate.responseTokenPresent === true
701+
|| alsoResponseTokenPresent === true,
696702
documentTimeOrigin,
697703
dialogAssociated: candidate.dialogAssociated === true
698704
|| responseFieldDialogAssociated === true
@@ -728,9 +734,12 @@ export function detectCaptchaCandidatesInPage(scope = null) {
728734
const responseFieldIndex = fields.indexOf(field);
729735
let responseFieldId = '';
730736
try { responseFieldId = String(field.id || field.getAttribute?.('id') || ''); } catch (_) {}
737+
let responseTokenPresent = false;
738+
try { responseTokenPresent = String(field.value || '').trim().length > 0; } catch (_) {}
731739
return {
732740
...(responseFieldId ? { responseFieldId } : {}),
733741
...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}),
742+
responseTokenPresent,
734743
...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}),
735744
};
736745
};
@@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
741750
...(Number.isInteger(identity.responseFieldIndex)
742751
? { alsoResponseFieldIndex: identity.responseFieldIndex }
743752
: {}),
753+
alsoResponseTokenPresent: identity.responseTokenPresent === true,
744754
...(identity.responseFieldDialogAssociated
745755
? { alsoResponseFieldDialogAssociated: true }
746756
: {}),

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ function candidateSummary(candidate) {
300300
recaptchaDataSValue: candidate?.recaptchaDataSValue || null,
301301
explicitWebsiteKey: candidate?.explicitWebsiteKey === true,
302302
callbackName: candidate?.callbackName || null,
303+
responseTokenPresent: candidate?.responseTokenPresent === true,
303304
responseFieldId: candidate?.responseFieldId || null,
304305
responseFieldIndex: Number.isInteger(candidate?.responseFieldIndex)
305306
? candidate.responseFieldIndex
@@ -407,6 +408,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
407408
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
408409
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
409410
responseField: previous.responseField === true || candidate.responseField === true,
411+
responseTokenPresent: previous.responseTokenPresent === true
412+
|| candidate.responseTokenPresent === true,
410413
};
411414
for (const field of taskParameterFields) {
412415
const previousValue = previous[field];
@@ -686,13 +689,16 @@ export function detectCaptchaCandidatesInPage(scope = null) {
686689
const {
687690
responseFieldDialogAssociated,
688691
alsoResponseFieldDialogAssociated,
692+
alsoResponseTokenPresent,
689693
...serializableCandidate
690694
} = candidate;
691695
candidates.push({
692696
...serializableCandidate,
693697
frameUrl,
694698
challengeFrame,
695699
responseField,
700+
responseTokenPresent: candidate.responseTokenPresent === true
701+
|| alsoResponseTokenPresent === true,
696702
documentTimeOrigin,
697703
dialogAssociated: candidate.dialogAssociated === true
698704
|| responseFieldDialogAssociated === true
@@ -728,9 +734,12 @@ export function detectCaptchaCandidatesInPage(scope = null) {
728734
const responseFieldIndex = fields.indexOf(field);
729735
let responseFieldId = '';
730736
try { responseFieldId = String(field.id || field.getAttribute?.('id') || ''); } catch (_) {}
737+
let responseTokenPresent = false;
738+
try { responseTokenPresent = String(field.value || '').trim().length > 0; } catch (_) {}
731739
return {
732740
...(responseFieldId ? { responseFieldId } : {}),
733741
...(responseFieldIndex >= 0 ? { responseFieldIndex } : {}),
742+
responseTokenPresent,
734743
...(elementInChallengeDialog(field) ? { responseFieldDialogAssociated: true } : {}),
735744
};
736745
};
@@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
741750
...(Number.isInteger(identity.responseFieldIndex)
742751
? { alsoResponseFieldIndex: identity.responseFieldIndex }
743752
: {}),
753+
alsoResponseTokenPresent: identity.responseTokenPresent === true,
744754
...(identity.responseFieldDialogAssociated
745755
? { alsoResponseFieldDialogAssociated: true }
746756
: {}),

test/run.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52992,6 +52992,7 @@ function captchaEl(tag, attrs = {}, children = []) {
5299252992
src: attrs.src,
5299352993
innerText: attrs.innerText || '',
5299452994
textContent: attrs.textContent || attrs.innerText || '',
52995+
value: attrs.value || '',
5299552996
hidden: attrs.hidden === true,
5299652997
style: {},
5299752998
classList: { contains: (c) => String(attrs.class || '').split(/\s+/).includes(c) },
@@ -53123,6 +53124,13 @@ async function detectCaptchaOnFakePage(build, nodes) {
5312353124
});
5312453125
}
5312553126

53127+
async function detectCaptchaDetailsOnFakePage(build, nodes) {
53128+
return withCaptchaFakePage(build, nodes, async () => {
53129+
const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href);
53130+
return mod.detectCaptcha(1);
53131+
});
53132+
}
53133+
5312653134
test('challenge-dialog routing detects supported widgets and diagnoses unsupported Arkose frames', async () => {
5312753135
for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) {
5312853136
const supportedNodes = [
@@ -53574,12 +53582,17 @@ test('challenge dialog with no enabled supported solver stops the batch for manu
5357453582
}
5357553583
});
5357653584

53577-
test('CAPTCHA gate helper stays byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
53585+
test('CAPTCHA helpers stay byte-identical and cloud traces retain gate diagnostics outside update rollover', () => {
5357853586
assert.equal(
5357953587
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-gate.js'), 'utf8'),
5358053588
fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-gate.js'), 'utf8'),
5358153589
'chrome and firefox CAPTCHA gate helpers must remain byte-identical',
5358253590
);
53591+
assert.equal(
53592+
fs.readFileSync(path.join(ROOT, 'src/chrome/src/agent/captcha-frame-runtime.js'), 'utf8'),
53593+
fs.readFileSync(path.join(ROOT, 'src/firefox/src/agent/captcha-frame-runtime.js'), 'utf8'),
53594+
'chrome and firefox CAPTCHA frame runtimes must remain byte-identical',
53595+
);
5358353596
const cloudSource = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8');
5358453597
assert.match(cloudSource, /if \(type === 'captcha_gate'\)[\s\S]*?run\.captchaDiagnostics = \{ \.\.\.scrubbedData, observedAt: run\.updatedAt \};/);
5358553598
assert.match(cloudSource, /\.\.\.\(run\.captchaDiagnostics \? \{ captchaDiagnostics: run\.captchaDiagnostics \} : \{\}\)/);
@@ -53839,6 +53852,110 @@ test('captcha detection binds the selected widget to its own response field', as
5383953852
}
5384053853
});
5384153854

53855+
test('captcha detection reports exact response token state without exposing token values', async () => {
53856+
const token = 'RAW_CAPTCHA_TOKEN_MUST_NOT_ESCAPE_7fc2';
53857+
for (const build of ['chrome', 'firefox']) {
53858+
const solvedField = captchaEl('textarea', {
53859+
id: 'g-recaptcha-response-solved',
53860+
name: 'g-recaptcha-response',
53861+
value: token,
53862+
});
53863+
const pendingField = captchaEl('textarea', {
53864+
id: 'g-recaptcha-response-pending',
53865+
name: 'g-recaptcha-response',
53866+
});
53867+
const solvedHost = captchaEl('div', {
53868+
class: 'g-recaptcha',
53869+
'data-sitekey': 'KEY_SOLVED_WIDGET',
53870+
hidden: true,
53871+
}, [solvedField]);
53872+
const pendingHost = captchaEl('div', {
53873+
class: 'g-recaptcha',
53874+
'data-sitekey': 'KEY_PENDING_WIDGET',
53875+
}, [pendingField]);
53876+
53877+
const pendingDetection = await detectCaptchaDetailsOnFakePage(build, [
53878+
solvedHost,
53879+
pendingHost,
53880+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53881+
]);
53882+
assert.equal(
53883+
pendingDetection.selected.responseTokenPresent,
53884+
false,
53885+
`${build}: a solved sibling widget contaminated the selected pending widget`,
53886+
);
53887+
assert.equal(
53888+
pendingDetection.candidates.find(candidate =>
53889+
candidate.websiteKey === 'KEY_SOLVED_WIDGET'
53890+
)?.responseTokenPresent,
53891+
true,
53892+
`${build}: non-empty exact response field was not reported as solved`,
53893+
);
53894+
assert.doesNotMatch(
53895+
JSON.stringify(pendingDetection),
53896+
new RegExp(token),
53897+
`${build}: raw response token escaped through detection`,
53898+
);
53899+
53900+
pendingField.value = token;
53901+
const solvedDetection = await detectCaptchaDetailsOnFakePage(build, [
53902+
solvedHost,
53903+
pendingHost,
53904+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53905+
]);
53906+
assert.equal(
53907+
solvedDetection.selected.responseTokenPresent,
53908+
true,
53909+
`${build}: selected non-empty response field was not reported as solved`,
53910+
);
53911+
assert.doesNotMatch(
53912+
JSON.stringify(solvedDetection),
53913+
new RegExp(token),
53914+
`${build}: selected raw response token escaped through detection`,
53915+
);
53916+
53917+
const primaryField = captchaEl('textarea', {
53918+
id: 'h-captcha-response-primary',
53919+
name: 'h-captcha-response',
53920+
});
53921+
const compatibilityField = captchaEl('textarea', {
53922+
id: 'g-recaptcha-response-compatibility',
53923+
name: 'g-recaptcha-response',
53924+
value: token,
53925+
});
53926+
const hcaptcha = await detectCaptchaOnFakePage(build, [
53927+
captchaEl('div', {
53928+
class: 'h-captcha',
53929+
'data-sitekey': 'HKEY_SOLVED_COMPATIBILITY',
53930+
}, [primaryField, compatibilityField]),
53931+
]);
53932+
assert.equal(
53933+
hcaptcha.responseTokenPresent,
53934+
true,
53935+
`${build}: solved hCaptcha compatibility field was ignored`,
53936+
);
53937+
assert.doesNotMatch(
53938+
JSON.stringify(hcaptcha),
53939+
new RegExp(token),
53940+
`${build}: hCaptcha compatibility token escaped through detection`,
53941+
);
53942+
53943+
Object.defineProperty(pendingField, 'value', {
53944+
configurable: true,
53945+
get() { throw new Error('hostile response field getter'); },
53946+
});
53947+
const unreadable = await detectCaptchaOnFakePage(build, [
53948+
pendingHost,
53949+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53950+
]);
53951+
assert.equal(
53952+
unreadable.responseTokenPresent,
53953+
false,
53954+
`${build}: unreadable response field did not fail closed`,
53955+
);
53956+
}
53957+
});
53958+
5384253959
test('captcha detection ranks a visible nested v2 Enterprise challenge above background v3', async () => {
5384353960
const topCandidate = {
5384453961
type: 'recaptcha_v3_enterprise',

0 commit comments

Comments
 (0)