Skip to content

Commit b9df6ec

Browse files
committed
refine CAPTCHA challenge labels
1 parent 5e2bab3 commit b9df6ec

7 files changed

Lines changed: 120 additions & 30 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2790,7 +2790,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
27902790
const execute = target => chrome.scripting.executeScript({
27912791
target,
27922792
func: detectChallengeDialogInPage,
2793-
args: [{ includeFrameContext: true }],
2793+
args: [{
2794+
includeFrameContext: true,
2795+
allowGenericFailure: options?.allowGenericFailure === true,
2796+
}],
27942797
});
27952798
const inspected = (results) => {
27962799
const entries = (results || []).map(entry => ({
@@ -2866,13 +2869,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
28662869
return { gate: null, loopCheck: { kind: 'none' } };
28672870
}
28682871

2869-
let challenge = detectChallengeDialog(toolResult.pageContent);
2872+
const activeGate = this._captchaGateStates.get(tabId);
2873+
let challenge = detectChallengeDialog(toolResult.pageContent, {
2874+
allowGenericFailure: !!activeGate,
2875+
});
28702876
if (!challenge && toolResult.pageGate?.surface === 'dialog' && toolResult.pageGate?.label) {
28712877
challenge = detectChallengeDialog(
2872-
`dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`
2878+
`dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`,
2879+
{ allowGenericFailure: !!activeGate },
28732880
);
28742881
}
2875-
const activeGate = this._captchaGateStates.get(tabId);
28762882
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
28772883
if (!pageUrl) {
28782884
try { pageUrl = await this._currentUrl(tabId); } catch {}
@@ -2907,6 +2913,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
29072913
const frameInspection = await this._detectChallengeDialogBeforeMutation(tabId, {
29082914
includeStatus: true,
29092915
expectedFrameId: activeGate.challengeFrameId,
2916+
allowGenericFailure: true,
29102917
});
29112918
if (frameInspection.challenge?.label) {
29122919
challenge = detectChallengeDialog(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
644644
return false;
645645
}
646646
};
647-
const challengeDialogRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
647+
const challengeDialogRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
648648
const challengeDialogs = Array.from(
649649
pageDocument.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')
650650
).filter((element) => {

src/chrome/src/agent/captcha-gate.js

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
const CHALLENGE_DIALOG_RE = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
1+
const CHALLENGE_DIALOG_RE = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
2+
const CHALLENGE_FAILURE_RE = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
3+
const CHALLENGE_CONTEXT_RE = /\b(?:captcha|human|robot|challenge)\b/i;
4+
5+
function matchesChallengeLabel(value, allowGenericFailure = false) {
6+
const text = String(value || '');
7+
return CHALLENGE_DIALOG_RE.test(text)
8+
|| (
9+
CHALLENGE_FAILURE_RE.test(text)
10+
&& (allowGenericFailure || CHALLENGE_CONTEXT_RE.test(text))
11+
);
12+
}
213

314
function normalizeChallengeLabel(value) {
415
return String(value || '')
@@ -34,15 +45,16 @@ function parseSerializedTreeLabel(line) {
3445
return '';
3546
}
3647

37-
export function detectChallengeDialog(pageContent) {
48+
export function detectChallengeDialog(pageContent, options = null) {
49+
const allowGenericFailure = options?.allowGenericFailure === true;
3850
const lines = String(pageContent || '').split(/\r?\n/);
3951
for (let index = 0; index < lines.length; index += 1) {
4052
const line = lines[index];
4153
const dialogMatch = line.match(/^(\s*)(?:dialog|alertdialog)(?=\s|$)/i);
4254
if (!dialogMatch) continue;
4355
const dialogIndent = dialogMatch[1].length;
4456
const ownLabel = parseSerializedTreeLabel(line);
45-
if (ownLabel && CHALLENGE_DIALOG_RE.test(ownLabel)) {
57+
if (ownLabel && matchesChallengeLabel(ownLabel, allowGenericFailure)) {
4658
return {
4759
label: ownLabel,
4860
normalizedLabel: normalizeChallengeLabel(ownLabel),
@@ -54,7 +66,7 @@ export function detectChallengeDialog(pageContent) {
5466
const childIndent = childLine.match(/^\s*/)?.[0].length || 0;
5567
if (childIndent <= dialogIndent) break;
5668
const childLabel = parseSerializedTreeLabel(childLine);
57-
if (!childLabel || !CHALLENGE_DIALOG_RE.test(childLabel)) continue;
69+
if (!childLabel || !matchesChallengeLabel(childLabel, allowGenericFailure)) continue;
5870
return {
5971
label: childLabel,
6072
normalizedLabel: normalizeChallengeLabel(childLabel),
@@ -68,6 +80,7 @@ export function detectChallengeDialog(pageContent) {
6880
// model-authored mutations. Keep this function self-contained.
6981
export function detectChallengeDialogInPage(options = null) {
7082
const includeFrameContext = options?.includeFrameContext === true;
83+
const allowGenericFailure = options?.allowGenericFailure === true;
7184
const pageWindow = typeof window !== 'undefined' ? window : null;
7285
const pageLocation = pageWindow?.location
7386
|| (typeof location !== 'undefined' ? location : null);
@@ -81,7 +94,17 @@ export function detectChallengeDialogInPage(options = null) {
8194
? { challenge: null, frameContext: { frameUrl, frameName, childFrames: [] } }
8295
: null;
8396
}
84-
const challengeRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
97+
const challengeRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
98+
const challengeFailureRe = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
99+
const challengeContextRe = /\b(?:captcha|human|robot|challenge)\b/i;
100+
const matchesChallenge = value => {
101+
const text = String(value || '');
102+
return challengeRe.test(text)
103+
|| (
104+
challengeFailureRe.test(text)
105+
&& (allowGenericFailure || challengeContextRe.test(text))
106+
);
107+
};
85108
const visible = (element) => {
86109
try {
87110
const style = getComputedStyle(element);
@@ -146,11 +169,11 @@ export function detectChallengeDialogInPage(options = null) {
146169
];
147170
for (const value of values) {
148171
const text = String(value || '');
149-
if (!challengeRe.test(text)) continue;
172+
if (!matchesChallenge(text)) continue;
150173
// Return the dialog's full label, not the matched keyword, so the gate
151174
// key built here matches the one built from the accessibility-tree
152175
// dialog name and the same challenge is never keyed two ways.
153-
const line = text.split(/\r?\n/).find(entry => challengeRe.test(entry)) || text;
176+
const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text;
154177
const label = line.replace(/\s+/g, ' ').trim().slice(0, 200);
155178
if (label) return finish({ label });
156179
}

src/firefox/src/agent/agent.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2457,7 +2457,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
24572457
if (!navigationFrames.length) {
24582458
navigationFrames = [{ frameId: 0, parentFrameId: -1, url: '' }];
24592459
}
2460-
const code = `(${detectChallengeDialogInPage.toString()})({ includeFrameContext: true })`;
2460+
const serializedOptions = JSON.stringify({
2461+
includeFrameContext: true,
2462+
allowGenericFailure: options?.allowGenericFailure === true,
2463+
});
2464+
const code = `(${detectChallengeDialogInPage.toString()})(${serializedOptions})`;
24612465
const frameEntries = await Promise.all(navigationFrames.map(async frame => {
24622466
try {
24632467
const results = await browser.tabs.executeScript(tabId, {
@@ -2531,13 +2535,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25312535
return { gate: null, loopCheck: { kind: 'none' } };
25322536
}
25332537

2534-
let challenge = detectChallengeDialog(toolResult.pageContent);
2538+
const activeGate = this._captchaGateStates.get(tabId);
2539+
let challenge = detectChallengeDialog(toolResult.pageContent, {
2540+
allowGenericFailure: !!activeGate,
2541+
});
25352542
if (!challenge && toolResult.pageGate?.surface === 'dialog' && toolResult.pageGate?.label) {
25362543
challenge = detectChallengeDialog(
2537-
`dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`
2544+
`dialog ${JSON.stringify(String(toolResult.pageGate.label).slice(0, 200))}`,
2545+
{ allowGenericFailure: !!activeGate },
25382546
);
25392547
}
2540-
const activeGate = this._captchaGateStates.get(tabId);
25412548
let pageUrl = String(toolResult.currentUrl || toolResult.pageUrl || '');
25422549
if (!pageUrl) {
25432550
try { pageUrl = await this._currentUrl(tabId); } catch {}
@@ -2572,6 +2579,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
25722579
const frameInspection = await this._detectChallengeDialogBeforeMutation(tabId, {
25732580
includeStatus: true,
25742581
expectedFrameId: activeGate.challengeFrameId,
2582+
allowGenericFailure: true,
25752583
});
25762584
if (frameInspection.challenge?.label) {
25772585
challenge = detectChallengeDialog(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ export function detectCaptchaCandidatesInPage(scope = null) {
644644
return false;
645645
}
646646
};
647-
const challengeDialogRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
647+
const challengeDialogRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
648648
const challengeDialogs = Array.from(
649649
pageDocument.querySelectorAll('dialog, [role="dialog"], [role="alertdialog"]')
650650
).filter((element) => {

src/firefox/src/agent/captcha-gate.js

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
const CHALLENGE_DIALOG_RE = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
1+
const CHALLENGE_DIALOG_RE = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
2+
const CHALLENGE_FAILURE_RE = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
3+
const CHALLENGE_CONTEXT_RE = /\b(?:captcha|human|robot|challenge)\b/i;
4+
5+
function matchesChallengeLabel(value, allowGenericFailure = false) {
6+
const text = String(value || '');
7+
return CHALLENGE_DIALOG_RE.test(text)
8+
|| (
9+
CHALLENGE_FAILURE_RE.test(text)
10+
&& (allowGenericFailure || CHALLENGE_CONTEXT_RE.test(text))
11+
);
12+
}
213

314
function normalizeChallengeLabel(value) {
415
return String(value || '')
@@ -34,15 +45,16 @@ function parseSerializedTreeLabel(line) {
3445
return '';
3546
}
3647

37-
export function detectChallengeDialog(pageContent) {
48+
export function detectChallengeDialog(pageContent, options = null) {
49+
const allowGenericFailure = options?.allowGenericFailure === true;
3850
const lines = String(pageContent || '').split(/\r?\n/);
3951
for (let index = 0; index < lines.length; index += 1) {
4052
const line = lines[index];
4153
const dialogMatch = line.match(/^(\s*)(?:dialog|alertdialog)(?=\s|$)/i);
4254
if (!dialogMatch) continue;
4355
const dialogIndent = dialogMatch[1].length;
4456
const ownLabel = parseSerializedTreeLabel(line);
45-
if (ownLabel && CHALLENGE_DIALOG_RE.test(ownLabel)) {
57+
if (ownLabel && matchesChallengeLabel(ownLabel, allowGenericFailure)) {
4658
return {
4759
label: ownLabel,
4860
normalizedLabel: normalizeChallengeLabel(ownLabel),
@@ -54,7 +66,7 @@ export function detectChallengeDialog(pageContent) {
5466
const childIndent = childLine.match(/^\s*/)?.[0].length || 0;
5567
if (childIndent <= dialogIndent) break;
5668
const childLabel = parseSerializedTreeLabel(childLine);
57-
if (!childLabel || !CHALLENGE_DIALOG_RE.test(childLabel)) continue;
69+
if (!childLabel || !matchesChallengeLabel(childLabel, allowGenericFailure)) continue;
5870
return {
5971
label: childLabel,
6072
normalizedLabel: normalizeChallengeLabel(childLabel),
@@ -68,6 +80,7 @@ export function detectChallengeDialog(pageContent) {
6880
// model-authored mutations. Keep this function self-contained.
6981
export function detectChallengeDialogInPage(options = null) {
7082
const includeFrameContext = options?.includeFrameContext === true;
83+
const allowGenericFailure = options?.allowGenericFailure === true;
7184
const pageWindow = typeof window !== 'undefined' ? window : null;
7285
const pageLocation = pageWindow?.location
7386
|| (typeof location !== 'undefined' ? location : null);
@@ -81,7 +94,17 @@ export function detectChallengeDialogInPage(options = null) {
8194
? { challenge: null, frameContext: { frameUrl, frameName, childFrames: [] } }
8295
: null;
8396
}
84-
const challengeRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re human|verify (?:that )?you are human|are you human|robot check|challenge verification|verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
97+
const challengeRe = /\b(?:captcha|security verification|human verification|verify (?:that )?you(?:'|\u2019)re (?:a )?human|verify (?:that )?you are (?:a )?human|are you (?:a )?human|robot check|challenge verification)\b/i;
98+
const challengeFailureRe = /\b(?:verification (?:failed|error|unsuccessful|expired|timed out)|could not verify|unable to verify)\b/i;
99+
const challengeContextRe = /\b(?:captcha|human|robot|challenge)\b/i;
100+
const matchesChallenge = value => {
101+
const text = String(value || '');
102+
return challengeRe.test(text)
103+
|| (
104+
challengeFailureRe.test(text)
105+
&& (allowGenericFailure || challengeContextRe.test(text))
106+
);
107+
};
85108
const visible = (element) => {
86109
try {
87110
const style = getComputedStyle(element);
@@ -146,11 +169,11 @@ export function detectChallengeDialogInPage(options = null) {
146169
];
147170
for (const value of values) {
148171
const text = String(value || '');
149-
if (!challengeRe.test(text)) continue;
172+
if (!matchesChallenge(text)) continue;
150173
// Return the dialog's full label, not the matched keyword, so the gate
151174
// key built here matches the one built from the accessibility-tree
152175
// dialog name and the same challenge is never keyed two ways.
153-
const line = text.split(/\r?\n/).find(entry => challengeRe.test(entry)) || text;
176+
const line = text.split(/\r?\n/).find(entry => matchesChallenge(entry)) || text;
154177
const label = line.replace(/\s+/g, ' ').trim().slice(0, 200);
155178
if (label) return finish({ label });
156179
}

test/run.js

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4713,18 +4713,37 @@ test('CAPTCHA dialog parsing handles descendant-only labels and escaped quotes',
47134713
{
47144714
tree: 'dialog [ref_1]\n heading "Security verification" [ref_2]\n button "Dismiss" [ref_3]',
47154715
label: 'Security verification',
4716+
normalized: /security verification/,
47164717
},
47174718
{
47184719
tree: String.raw`dialog "Complete \"Security verification\" now" [ref_4]`,
47194720
label: 'Complete "Security verification" now',
4721+
normalized: /security verification/,
4722+
},
4723+
{
4724+
tree: 'dialog "Verify that you\u2019re a human" [ref_5]',
4725+
label: 'Verify that you\u2019re a human',
4726+
normalized: /verify that you re a human/,
47204727
},
47214728
];
47224729
for (const [build, gate] of [['chrome', CaptchaGateCh], ['firefox', CaptchaGateFx]]) {
47234730
for (const example of cases) {
47244731
const challenge = gate.detectChallengeDialog(example.tree);
47254732
assert.equal(challenge?.label, example.label, `${build}: failed to parse ${example.tree}`);
4726-
assert.match(challenge?.normalizedLabel || '', /security verification/, `${build}: normalized challenge label missing`);
4733+
assert.match(challenge?.normalizedLabel || '', example.normalized, `${build}: normalized challenge label missing`);
47274734
}
4735+
const genericFailure = 'dialog "Email verification failed" [ref_6]';
4736+
assert.equal(gate.detectChallengeDialog(genericFailure), null, `${build}: generic application verification failure armed a CAPTCHA gate`);
4737+
assert.equal(
4738+
gate.detectChallengeDialog(genericFailure, { allowGenericFailure: true })?.label,
4739+
'Email verification failed',
4740+
`${build}: active CAPTCHA could not retain a renamed failure dialog`,
4741+
);
4742+
assert.equal(
4743+
gate.detectChallengeDialog('dialog "CAPTCHA verification failed" [ref_7]')?.label,
4744+
'CAPTCHA verification failed',
4745+
`${build}: contextual CAPTCHA failure was missed`,
4746+
);
47284747
}
47294748
});
47304749

@@ -4753,9 +4772,19 @@ test('CAPTCHA mutation preflight ignores hidden and off-viewport verification di
47534772
});
47544773
}
47554774
await withCaptchaFakePage(build, [
4756-
captchaEl('div', { role: 'dialog', innerText: 'Security verification' }),
4775+
captchaEl('div', { role: 'dialog', innerText: 'Verify you are a human' }),
47574776
], async () => {
4758-
assert.equal(gate.detectChallengeDialogInPage()?.label, 'Security verification', `${build}: visible dialog was missed`);
4777+
assert.equal(gate.detectChallengeDialogInPage()?.label, 'Verify you are a human', `${build}: article-bearing challenge dialog was missed`);
4778+
});
4779+
await withCaptchaFakePage(build, [
4780+
captchaEl('div', { role: 'dialog', innerText: 'Email verification failed' }),
4781+
], async () => {
4782+
assert.equal(gate.detectChallengeDialogInPage(), null, `${build}: generic application failure armed preflight`);
4783+
assert.equal(
4784+
gate.detectChallengeDialogInPage({ allowGenericFailure: true })?.label,
4785+
'Email verification failed',
4786+
`${build}: active-gate failure context was ignored`,
4787+
);
47594788
});
47604789
}
47614790
});
@@ -52543,9 +52572,9 @@ test('challenge-dialog routing detects supported widgets and diagnoses unsupport
5254352572
}),
5254452573
captchaEl('div', {
5254552574
role: 'dialog',
52546-
innerText: 'Verify that you\u2019re human',
52575+
innerText: 'Verify that you\u2019re a human',
5254752576
}, [
52548-
captchaEl('h2', { textContent: 'Verify that you\u2019re human' }),
52577+
captchaEl('h2', { textContent: 'Verify that you\u2019re a human' }),
5254952578
captchaEl('div', {
5255052579
class: 'g-recaptcha g-recaptcha-v3',
5255152580
'data-sitekey': 'DIALOG_V3_KEY',
@@ -52562,7 +52591,7 @@ test('challenge-dialog routing detects supported widgets and diagnoses unsupport
5256252591
agent.captchaSolverEnabled = true;
5256352592
agent._currentUrl = async () => 'https://example.test/signup';
5256452593
const observed = await agent._observeCaptchaChallenge(1, 'get_accessibility_tree', {
52565-
pageContent: 'dialog "Verify that you\u2019re human" [ref_150]\n button "Dismiss" [ref_151]',
52594+
pageContent: 'dialog "Verify that you\u2019re a human" [ref_150]\n button "Dismiss" [ref_151]',
5256652595
});
5256752596
assert.equal(observed.gate?.status, 'solve_required', `${build}: invisible v3 widget inside the active dialog was not routable`);
5256852597
assert.equal(observed.gate?.selectedType, 'recaptcha_v3_enterprise', `${build}: dialog-associated v3 type was lost`);

0 commit comments

Comments
 (0)