Skip to content

Commit 7e9cf42

Browse files
fix(captcha): report response token state
1 parent 86dc7eb commit 7e9cf42

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
@@ -52554,6 +52554,7 @@ function captchaEl(tag, attrs = {}, children = []) {
5255452554
src: attrs.src,
5255552555
innerText: attrs.innerText || '',
5255652556
textContent: attrs.textContent || attrs.innerText || '',
52557+
value: attrs.value || '',
5255752558
hidden: attrs.hidden === true,
5255852559
style: {},
5255952560
classList: { contains: (c) => String(attrs.class || '').split(/\s+/).includes(c) },
@@ -52685,6 +52686,13 @@ async function detectCaptchaOnFakePage(build, nodes) {
5268552686
});
5268652687
}
5268752688

52689+
async function detectCaptchaDetailsOnFakePage(build, nodes) {
52690+
return withCaptchaFakePage(build, nodes, async () => {
52691+
const mod = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/captcha-solver.js`)).href);
52692+
return mod.detectCaptcha(1);
52693+
});
52694+
}
52695+
5268852696
test('challenge-dialog routing detects supported widgets and diagnoses unsupported Arkose frames', async () => {
5268952697
for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) {
5269052698
const supportedNodes = [
@@ -53136,12 +53144,17 @@ test('challenge dialog with no enabled supported solver stops the batch for manu
5313653144
}
5313753145
});
5313853146

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

53417+
test('captcha detection reports exact response token state without exposing token values', async () => {
53418+
const token = 'RAW_CAPTCHA_TOKEN_MUST_NOT_ESCAPE_7fc2';
53419+
for (const build of ['chrome', 'firefox']) {
53420+
const solvedField = captchaEl('textarea', {
53421+
id: 'g-recaptcha-response-solved',
53422+
name: 'g-recaptcha-response',
53423+
value: token,
53424+
});
53425+
const pendingField = captchaEl('textarea', {
53426+
id: 'g-recaptcha-response-pending',
53427+
name: 'g-recaptcha-response',
53428+
});
53429+
const solvedHost = captchaEl('div', {
53430+
class: 'g-recaptcha',
53431+
'data-sitekey': 'KEY_SOLVED_WIDGET',
53432+
hidden: true,
53433+
}, [solvedField]);
53434+
const pendingHost = captchaEl('div', {
53435+
class: 'g-recaptcha',
53436+
'data-sitekey': 'KEY_PENDING_WIDGET',
53437+
}, [pendingField]);
53438+
53439+
const pendingDetection = await detectCaptchaDetailsOnFakePage(build, [
53440+
solvedHost,
53441+
pendingHost,
53442+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53443+
]);
53444+
assert.equal(
53445+
pendingDetection.selected.responseTokenPresent,
53446+
false,
53447+
`${build}: a solved sibling widget contaminated the selected pending widget`,
53448+
);
53449+
assert.equal(
53450+
pendingDetection.candidates.find(candidate =>
53451+
candidate.websiteKey === 'KEY_SOLVED_WIDGET'
53452+
)?.responseTokenPresent,
53453+
true,
53454+
`${build}: non-empty exact response field was not reported as solved`,
53455+
);
53456+
assert.doesNotMatch(
53457+
JSON.stringify(pendingDetection),
53458+
new RegExp(token),
53459+
`${build}: raw response token escaped through detection`,
53460+
);
53461+
53462+
pendingField.value = token;
53463+
const solvedDetection = await detectCaptchaDetailsOnFakePage(build, [
53464+
solvedHost,
53465+
pendingHost,
53466+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53467+
]);
53468+
assert.equal(
53469+
solvedDetection.selected.responseTokenPresent,
53470+
true,
53471+
`${build}: selected non-empty response field was not reported as solved`,
53472+
);
53473+
assert.doesNotMatch(
53474+
JSON.stringify(solvedDetection),
53475+
new RegExp(token),
53476+
`${build}: selected raw response token escaped through detection`,
53477+
);
53478+
53479+
const primaryField = captchaEl('textarea', {
53480+
id: 'h-captcha-response-primary',
53481+
name: 'h-captcha-response',
53482+
});
53483+
const compatibilityField = captchaEl('textarea', {
53484+
id: 'g-recaptcha-response-compatibility',
53485+
name: 'g-recaptcha-response',
53486+
value: token,
53487+
});
53488+
const hcaptcha = await detectCaptchaOnFakePage(build, [
53489+
captchaEl('div', {
53490+
class: 'h-captcha',
53491+
'data-sitekey': 'HKEY_SOLVED_COMPATIBILITY',
53492+
}, [primaryField, compatibilityField]),
53493+
]);
53494+
assert.equal(
53495+
hcaptcha.responseTokenPresent,
53496+
true,
53497+
`${build}: solved hCaptcha compatibility field was ignored`,
53498+
);
53499+
assert.doesNotMatch(
53500+
JSON.stringify(hcaptcha),
53501+
new RegExp(token),
53502+
`${build}: hCaptcha compatibility token escaped through detection`,
53503+
);
53504+
53505+
Object.defineProperty(pendingField, 'value', {
53506+
configurable: true,
53507+
get() { throw new Error('hostile response field getter'); },
53508+
});
53509+
const unreadable = await detectCaptchaOnFakePage(build, [
53510+
pendingHost,
53511+
captchaEl('script', { src: 'https://www.google.com/recaptcha/api.js' }),
53512+
]);
53513+
assert.equal(
53514+
unreadable.responseTokenPresent,
53515+
false,
53516+
`${build}: unreadable response field did not fail closed`,
53517+
);
53518+
}
53519+
});
53520+
5340453521
test('captcha detection ranks a visible nested v2 Enterprise challenge above background v3', async () => {
5340553522
const topCandidate = {
5340653523
type: 'recaptcha_v3_enterprise',

0 commit comments

Comments
 (0)