Skip to content

Commit 10a215c

Browse files
authored
Merge pull request #2615 from alectimison-maker/fix/captcha-active-challenge-frames
fix(captcha): detect active challenge frames
2 parents 8753cf0 + c33755d commit 10a215c

7 files changed

Lines changed: 723 additions & 50 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3082,6 +3082,56 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
30823082
if (!pageUrl) {
30833083
try { pageUrl = await this._currentUrl(tabId); } catch {}
30843084
}
3085+
let detection = null;
3086+
let detectionFailed = false;
3087+
let failedDiagnostics = null;
3088+
let detectionAttempted = false;
3089+
const inspectCaptchaFrames = async () => {
3090+
if (detectionAttempted) return;
3091+
detectionAttempted = true;
3092+
try {
3093+
detection = await detectCaptcha(tabId);
3094+
} catch (error) {
3095+
detectionFailed = true;
3096+
failedDiagnostics = error?.captchaDiagnostics || null;
3097+
}
3098+
};
3099+
let languageNeutralFrameTrigger = false;
3100+
const hasDialogSurface = toolResult.pageGate?.surface === 'dialog'
3101+
|| /^\s*(?:dialog|alertdialog)(?=\s|$)/im.test(toolResult.pageContent);
3102+
if (!challenge && hasDialogSurface) {
3103+
await inspectCaptchaFrames();
3104+
const selectedActiveFrame = detection?.selected?.activeChallengeFrame === true
3105+
&& detection?.selected?.activeChallengeFrameVisible === true
3106+
&& detection?.selected?.visible === true
3107+
&& detection?.selected?.frameVisible !== false;
3108+
const diagnosticActiveFrame = (detection?.diagnostics?.frames || []).find(frame =>
3109+
frame?.activeChallengeFrame === true && frame?.visible === true
3110+
);
3111+
if (selectedActiveFrame || diagnosticActiveFrame) {
3112+
const vendor = diagnosticActiveFrame?.vendor
3113+
|| (String(detection?.selected?.type || '').startsWith('recaptcha')
3114+
? 'recaptcha'
3115+
: detection?.selected?.type || 'captcha');
3116+
const vendorLabel = vendor === 'recaptcha'
3117+
? 'reCAPTCHA'
3118+
: vendor === 'hcaptcha'
3119+
? 'hCaptcha'
3120+
: vendor === 'arkose'
3121+
? 'Arkose'
3122+
: 'CAPTCHA';
3123+
challenge = detectChallengeDialog(`dialog ${JSON.stringify(`Visible ${vendorLabel} CAPTCHA challenge`)}`);
3124+
languageNeutralFrameTrigger = !!challenge;
3125+
if (selectedActiveFrame) {
3126+
observedChallengeFrameId = Number.isInteger(detection.selected.frameId)
3127+
? detection.selected.frameId
3128+
: observedChallengeFrameId;
3129+
observedChallengeFrameUrl = String(
3130+
detection.selected.frameUrl || observedChallengeFrameUrl
3131+
);
3132+
}
3133+
}
3134+
}
30853135
const requestedPage = toolArgs?.page;
30863136
const requestedMaxDepth = toolArgs?.maxDepth;
30873137
const parsedMaxDepth = Number(requestedMaxDepth);
@@ -3232,17 +3282,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
32323282
return { gate: existing.publicGate, loopCheck };
32333283
}
32343284

3235-
let detection = null;
3236-
let detectionFailed = false;
3237-
let failedDiagnostics = null;
3238-
if (this.captchaSolverEnabled) {
3239-
try {
3240-
detection = await detectCaptcha(tabId);
3241-
} catch (error) {
3242-
detectionFailed = true;
3243-
failedDiagnostics = error?.captchaDiagnostics || null;
3244-
}
3245-
}
3285+
await inspectCaptchaFrames();
32463286
const diagnostics = detection?.diagnostics || failedDiagnostics || {
32473287
vendors: [],
32483288
candidateTypes: [],
@@ -3256,8 +3296,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
32563296
&& frame?.visible === true
32573297
))
32583298
.map(frame => frame.vendor))];
3259-
const selectedCorrelated = detection?.selected?.dialogAssociated === true
3260-
&& detection?.selected?.frameVisible !== false;
3299+
const selectedCorrelated = detection?.selected?.frameVisible !== false
3300+
&& (
3301+
detection?.selected?.dialogAssociated === true
3302+
|| (
3303+
detection?.selected?.activeChallengeFrame === true
3304+
&& detection?.selected?.activeChallengeFrameVisible === true
3305+
&& detection?.selected?.visible === true
3306+
)
3307+
);
32613308
const supported = this.captchaSolverEnabled
32623309
&& !detectionFailed
32633310
&& !detection?.error
@@ -3274,6 +3321,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
32743321
...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}),
32753322
...(detection?.error ? { selectionFailed: true } : {}),
32763323
...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}),
3324+
...(languageNeutralFrameTrigger ? { languageNeutralFrameTrigger: true } : {}),
32773325
};
32783326
this._captchaGateStates.set(tabId, {
32793327
key,

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

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,18 +263,28 @@ export function applyCaptchaFrameVisibility(candidates, frameContexts, navigatio
263263
const candidate = reconcileAncestorLoader(rawCandidate);
264264
const frameVisible = frameIsVisible(candidate?.frameId)
265265
&& candidate?.frameVisibleWithinAnchor !== false;
266+
const visible = candidate?.visible === true && frameVisible;
266267
return {
267268
...candidate,
268269
frameVisible,
269270
websiteURL: nearestHttpUrl(candidate),
270271
dialogAssociated: candidate?.dialogAssociated === true
271272
|| frameIsDialogAssociated(candidate?.frameId),
272-
visible: candidate?.visible === true && frameVisible,
273+
visible,
273274
normalCheckbox: candidate?.normalCheckbox === true && candidate?.visible === true && frameVisible,
275+
activeChallengeFrameVisible: candidate?.activeChallengeFrame === true && visible,
274276
};
275277
});
276278
}
277279

280+
function isVisibleActiveChallengeFrame(candidate) {
281+
if (candidate?.activeChallengeFrameVisible === true) return true;
282+
if (candidate?.activeChallengeFrameVisible === false) return false;
283+
return candidate?.activeChallengeFrame === true
284+
&& candidate?.visible === true
285+
&& candidate?.frameVisible !== false;
286+
}
287+
278288
function candidateSummary(candidate) {
279289
return {
280290
frameId: Number.isInteger(candidate?.frameId) ? candidate.frameId : null,
@@ -291,6 +301,8 @@ function candidateSummary(candidate) {
291301
visible: candidate?.visible === true,
292302
normalCheckbox: candidate?.normalCheckbox === true,
293303
challengeFrame: candidate?.challengeFrame === true,
304+
activeChallengeFrame: candidate?.activeChallengeFrame === true,
305+
activeChallengeFrameVisible: isVisibleActiveChallengeFrame(candidate),
294306
dialogAssociated: candidate?.dialogAssociated === true,
295307
frameVisible: candidate?.frameVisible !== false,
296308
isInvisible: candidate?.isInvisible === true,
@@ -326,9 +338,7 @@ function candidateScore(candidate) {
326338
// Priority is tiered so no combination of secondary signals can make a
327339
// generic visible/background integration outrank an active challenge
328340
// frame or visible checkbox.
329-
const activeChallengeFrame = candidate?.challengeFrame
330-
&& candidate?.visible === true
331-
&& candidate?.frameVisible !== false;
341+
const activeChallengeFrame = isVisibleActiveChallengeFrame(candidate);
332342
const primary = (candidate?.normalCheckbox && candidate?.visible) || activeChallengeFrame;
333343
const tier = primary ? 3 : (candidate?.visible ? 2 : 1);
334344
let score = tier * 1000;
@@ -406,6 +416,10 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
406416
visible: previous.visible === true || candidate.visible === true,
407417
normalCheckbox: previous.normalCheckbox === true || candidate.normalCheckbox === true,
408418
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
419+
activeChallengeFrame: previous.activeChallengeFrame === true
420+
|| candidate.activeChallengeFrame === true,
421+
activeChallengeFrameVisible: isVisibleActiveChallengeFrame(previous)
422+
|| isVisibleActiveChallengeFrame(candidate),
409423
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
410424
responseField: previous.responseField === true || candidate.responseField === true,
411425
responseTokenPresent: previous.responseTokenPresent === true
@@ -580,7 +594,7 @@ function selectedReason(candidate, constraints) {
580594
if (constraints.frameUrl) return 'exact frameUrl match';
581595
if (constraints.websiteKey) return 'exact websiteKey match';
582596
if (candidate.normalCheckbox && candidate.visible) return 'visible checkbox challenge';
583-
if (candidate.visible && candidate.challengeFrame && candidate.frameVisible !== false) return 'visible challenge frame';
597+
if (isVisibleActiveChallengeFrame(candidate)) return 'visible challenge frame';
584598
if (candidate.visible) return 'visible CAPTCHA widget';
585599
if (candidate.challengeFrame && candidate.frameVisible !== false) return 'challenge frame candidate';
586600
return 'only detected CAPTCHA candidate';
@@ -627,6 +641,29 @@ export function detectCaptchaCandidatesInPage(scope = null) {
627641
return null;
628642
}
629643
};
644+
// Page-serialized counterpart of captchaActiveChallengeFrameVendor in
645+
// captcha-gate.js. Only challenge routes, never checkbox routes, may arm it.
646+
const activeChallengeFrameVendor = (urlStr) => {
647+
try {
648+
const parsed = new URL(String(urlStr || ''), frameUrl || 'https://dummy.host');
649+
const host = parsed.hostname.toLowerCase();
650+
const path = parsed.pathname.toLowerCase();
651+
if (
652+
(/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host))
653+
&& /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path)
654+
) return 'recaptcha';
655+
if (/(^|\.)hcaptcha\.com$/.test(host)) {
656+
const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame');
657+
if (frame === 'challenge') return 'hcaptcha';
658+
}
659+
if (
660+
/(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host)
661+
&& /\/fc\/gc(?:\/|$)/.test(path)
662+
) return 'arkose';
663+
} catch (_) {}
664+
return '';
665+
};
666+
const documentChallengeVendor = activeChallengeFrameVendor(frameUrl);
630667
const visibleElement = (element) => {
631668
if (!element) return false;
632669
try {
@@ -714,7 +751,9 @@ export function detectCaptchaCandidatesInPage(scope = null) {
714751
candidates.push({
715752
...serializableCandidate,
716753
frameUrl,
717-
challengeFrame,
754+
challengeFrame: candidate.challengeFrame === true || challengeFrame,
755+
activeChallengeFrame: candidate.activeChallengeFrame === true
756+
|| !!documentChallengeVendor,
718757
responseField,
719758
responseTokenPresent: candidate.responseTokenPresent === true
720759
|| alsoResponseTokenPresent === true,
@@ -873,16 +912,21 @@ export function detectCaptchaCandidatesInPage(scope = null) {
873912
const recaptchaFrames = iframeUrls.filter(({ url }) =>
874913
/recaptcha\/(api2|enterprise)\/anchor/i.test(url)
875914
);
915+
const recaptchaChallengeFrames = iframeUrls.filter(({ url }) =>
916+
activeChallengeFrameVendor(url) === 'recaptcha'
917+
);
876918

877919
for (const { element, url } of iframeUrls) {
878920
if (/hcaptcha\.com/i.test(url)) {
879921
const websiteKey = urlParam(url, 'sitekey');
880922
if (websiteKey) {
923+
const activeChallengeFrame = activeChallengeFrameVendor(url) === 'hcaptcha';
881924
add({
882925
type: 'hcaptcha',
883926
websiteKey,
884927
visible: visibleElement(element),
885-
normalCheckbox: visibleElement(element),
928+
normalCheckbox: visibleElement(element) && !activeChallengeFrame,
929+
activeChallengeFrame,
886930
...responseFieldIdentity(
887931
element,
888932
'h-captcha-response',
@@ -919,10 +963,31 @@ export function detectCaptchaCandidatesInPage(scope = null) {
919963
}
920964
continue;
921965
}
922-
if (!/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
966+
const recaptchaChallengeFrame = activeChallengeFrameVendor(url) === 'recaptcha';
967+
if (!recaptchaChallengeFrame && !/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
923968
const websiteKey = urlParam(url, 'k');
924969
if (!websiteKey) continue;
925970
const isEnterprise = /recaptcha\/enterprise/i.test(url);
971+
if (recaptchaChallengeFrame) {
972+
const challengeIndex = recaptchaChallengeFrames.findIndex(frame => frame.element === element);
973+
add({
974+
type: isEnterprise ? 'recaptcha_v2_enterprise' : 'recaptcha_v2',
975+
websiteKey,
976+
isInvisible: false,
977+
isEnterprise,
978+
visible: visibleElement(element),
979+
normalCheckbox: false,
980+
activeChallengeFrame: true,
981+
...responseFieldIdentity(
982+
element,
983+
'g-recaptcha-response',
984+
challengeIndex,
985+
recaptchaChallengeFrames.length,
986+
),
987+
detectedVia: 'url',
988+
}, element);
989+
continue;
990+
}
926991
const isInvisible = urlParam(url, 'size') === 'invisible';
927992
const matchingV3Script = scriptUrls.find(scriptUrl => urlParam(scriptUrl, 'render') === websiteKey) || null;
928993
const isV3 = !!matchingV3Script;
@@ -994,13 +1059,15 @@ export function detectCaptchaCandidatesInPage(scope = null) {
9941059
try { url = String(element.src || ''); } catch (_) {}
9951060
try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch (_) {}
9961061
try { name = String(element.name || element.getAttribute?.('name') || ''); } catch (_) {}
1062+
const activeChallengeFrame = !!activeChallengeFrameVendor(loadedUrl || url);
9971063
return {
9981064
index,
9991065
url,
10001066
loadedUrl,
10011067
name,
10021068
visible: visibleElement(element),
10031069
dialogAssociated: elementInChallengeDialog(element),
1070+
activeChallengeFrame,
10041071
};
10051072
});
10061073
let frameName = '';

0 commit comments

Comments
 (0)