From 7e9cf4276206b4182becffe7fd5b2e4e6d9ffeb0 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sun, 26 Jul 2026 14:31:36 +0800 Subject: [PATCH] fix(captcha): report response token state --- src/chrome/src/agent/captcha-frame-runtime.js | 10 ++ .../src/agent/captcha-frame-runtime.js | 10 ++ test/run.js | 119 +++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/agent/captcha-frame-runtime.js b/src/chrome/src/agent/captcha-frame-runtime.js index 64675cc70..63f0fa6ac 100644 --- a/src/chrome/src/agent/captcha-frame-runtime.js +++ b/src/chrome/src/agent/captcha-frame-runtime.js @@ -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 @@ -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]; @@ -686,6 +689,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { const { responseFieldDialogAssociated, alsoResponseFieldDialogAssociated, + alsoResponseTokenPresent, ...serializableCandidate } = candidate; candidates.push({ @@ -693,6 +697,8 @@ export function detectCaptchaCandidatesInPage(scope = null) { frameUrl, challengeFrame, responseField, + responseTokenPresent: candidate.responseTokenPresent === true + || alsoResponseTokenPresent === true, documentTimeOrigin, dialogAssociated: candidate.dialogAssociated === true || responseFieldDialogAssociated === true @@ -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 } : {}), }; }; @@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...(Number.isInteger(identity.responseFieldIndex) ? { alsoResponseFieldIndex: identity.responseFieldIndex } : {}), + alsoResponseTokenPresent: identity.responseTokenPresent === true, ...(identity.responseFieldDialogAssociated ? { alsoResponseFieldDialogAssociated: true } : {}), diff --git a/src/firefox/src/agent/captcha-frame-runtime.js b/src/firefox/src/agent/captcha-frame-runtime.js index 64675cc70..63f0fa6ac 100644 --- a/src/firefox/src/agent/captcha-frame-runtime.js +++ b/src/firefox/src/agent/captcha-frame-runtime.js @@ -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 @@ -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]; @@ -686,6 +689,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { const { responseFieldDialogAssociated, alsoResponseFieldDialogAssociated, + alsoResponseTokenPresent, ...serializableCandidate } = candidate; candidates.push({ @@ -693,6 +697,8 @@ export function detectCaptchaCandidatesInPage(scope = null) { frameUrl, challengeFrame, responseField, + responseTokenPresent: candidate.responseTokenPresent === true + || alsoResponseTokenPresent === true, documentTimeOrigin, dialogAssociated: candidate.dialogAssociated === true || responseFieldDialogAssociated === true @@ -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 } : {}), }; }; @@ -741,6 +750,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { ...(Number.isInteger(identity.responseFieldIndex) ? { alsoResponseFieldIndex: identity.responseFieldIndex } : {}), + alsoResponseTokenPresent: identity.responseTokenPresent === true, ...(identity.responseFieldDialogAssociated ? { alsoResponseFieldDialogAssociated: true } : {}), diff --git a/test/run.js b/test/run.js index 8d284e3c0..be22bba29 100644 --- a/test/run.js +++ b/test/run.js @@ -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) }, @@ -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 = [ @@ -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 \} : \{\}\)/); @@ -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',