Skip to content

Commit 59155f3

Browse files
fix(captcha): detect active challenge frames
1 parent a696af5 commit 59155f3

7 files changed

Lines changed: 515 additions & 40 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3007,6 +3007,55 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
30073007
if (!pageUrl) {
30083008
try { pageUrl = await this._currentUrl(tabId); } catch {}
30093009
}
3010+
let detection = null;
3011+
let detectionFailed = false;
3012+
let failedDiagnostics = null;
3013+
let detectionAttempted = false;
3014+
const inspectCaptchaFrames = async () => {
3015+
if (detectionAttempted) return;
3016+
detectionAttempted = true;
3017+
try {
3018+
detection = await detectCaptcha(tabId);
3019+
} catch (error) {
3020+
detectionFailed = true;
3021+
failedDiagnostics = error?.captchaDiagnostics || null;
3022+
}
3023+
};
3024+
let languageNeutralFrameTrigger = false;
3025+
const hasDialogSurface = toolResult.pageGate?.surface === 'dialog'
3026+
|| /^\s*(?:dialog|alertdialog)(?=\s|$)/im.test(toolResult.pageContent);
3027+
if (!challenge && hasDialogSurface) {
3028+
await inspectCaptchaFrames();
3029+
const selectedActiveFrame = detection?.selected?.activeChallengeFrame === true
3030+
&& detection?.selected?.visible === true
3031+
&& detection?.selected?.frameVisible !== false;
3032+
const diagnosticActiveFrame = (detection?.diagnostics?.frames || []).find(frame =>
3033+
frame?.activeChallengeFrame === true && frame?.visible === true
3034+
);
3035+
if (selectedActiveFrame || diagnosticActiveFrame) {
3036+
const vendor = diagnosticActiveFrame?.vendor
3037+
|| (String(detection?.selected?.type || '').startsWith('recaptcha')
3038+
? 'recaptcha'
3039+
: detection?.selected?.type || 'captcha');
3040+
const vendorLabel = vendor === 'recaptcha'
3041+
? 'reCAPTCHA'
3042+
: vendor === 'hcaptcha'
3043+
? 'hCaptcha'
3044+
: vendor === 'arkose'
3045+
? 'Arkose'
3046+
: 'CAPTCHA';
3047+
challenge = detectChallengeDialog(`dialog ${JSON.stringify(`Visible ${vendorLabel} CAPTCHA challenge`)}`);
3048+
languageNeutralFrameTrigger = !!challenge;
3049+
if (selectedActiveFrame) {
3050+
observedChallengeFrameId = Number.isInteger(detection.selected.frameId)
3051+
? detection.selected.frameId
3052+
: observedChallengeFrameId;
3053+
observedChallengeFrameUrl = String(
3054+
detection.selected.frameUrl || observedChallengeFrameUrl
3055+
);
3056+
}
3057+
}
3058+
}
30103059
const requestedPage = toolArgs?.page;
30113060
const requestedMaxDepth = toolArgs?.maxDepth;
30123061
const parsedMaxDepth = Number(requestedMaxDepth);
@@ -3157,17 +3206,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
31573206
return { gate: existing.publicGate, loopCheck };
31583207
}
31593208

3160-
let detection = null;
3161-
let detectionFailed = false;
3162-
let failedDiagnostics = null;
3163-
if (this.captchaSolverEnabled) {
3164-
try {
3165-
detection = await detectCaptcha(tabId);
3166-
} catch (error) {
3167-
detectionFailed = true;
3168-
failedDiagnostics = error?.captchaDiagnostics || null;
3169-
}
3170-
}
3209+
await inspectCaptchaFrames();
31713210
const diagnostics = detection?.diagnostics || failedDiagnostics || {
31723211
vendors: [],
31733212
candidateTypes: [],
@@ -3181,8 +3220,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
31813220
&& frame?.visible === true
31823221
))
31833222
.map(frame => frame.vendor))];
3184-
const selectedCorrelated = detection?.selected?.dialogAssociated === true
3185-
&& detection?.selected?.frameVisible !== false;
3223+
const selectedCorrelated = (
3224+
detection?.selected?.dialogAssociated === true
3225+
|| detection?.selected?.activeChallengeFrame === true
3226+
) && detection?.selected?.frameVisible !== false;
31863227
const supported = this.captchaSolverEnabled
31873228
&& !detectionFailed
31883229
&& !detection?.error
@@ -3199,6 +3240,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
31993240
...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}),
32003241
...(detection?.error ? { selectionFailed: true } : {}),
32013242
...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}),
3243+
...(languageNeutralFrameTrigger ? { languageNeutralFrameTrigger: true } : {}),
32023244
};
32033245
this._captchaGateStates.set(tabId, {
32043246
key,

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ function candidateSummary(candidate) {
291291
visible: candidate?.visible === true,
292292
normalCheckbox: candidate?.normalCheckbox === true,
293293
challengeFrame: candidate?.challengeFrame === true,
294+
activeChallengeFrame: candidate?.activeChallengeFrame === true,
294295
dialogAssociated: candidate?.dialogAssociated === true,
295296
frameVisible: candidate?.frameVisible !== false,
296297
isInvisible: candidate?.isInvisible === true,
@@ -326,7 +327,7 @@ function candidateScore(candidate) {
326327
// Priority is tiered so no combination of secondary signals can make a
327328
// generic visible/background integration outrank an active challenge
328329
// frame or visible checkbox.
329-
const activeChallengeFrame = candidate?.challengeFrame
330+
const activeChallengeFrame = candidate?.activeChallengeFrame
330331
&& candidate?.visible === true
331332
&& candidate?.frameVisible !== false;
332333
const primary = (candidate?.normalCheckbox && candidate?.visible) || activeChallengeFrame;
@@ -406,6 +407,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) {
406407
visible: previous.visible === true || candidate.visible === true,
407408
normalCheckbox: previous.normalCheckbox === true || candidate.normalCheckbox === true,
408409
challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true,
410+
activeChallengeFrame: previous.activeChallengeFrame === true
411+
|| candidate.activeChallengeFrame === true,
409412
dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true,
410413
responseField: previous.responseField === true || candidate.responseField === true,
411414
responseTokenPresent: previous.responseTokenPresent === true
@@ -580,7 +583,7 @@ function selectedReason(candidate, constraints) {
580583
if (constraints.frameUrl) return 'exact frameUrl match';
581584
if (constraints.websiteKey) return 'exact websiteKey match';
582585
if (candidate.normalCheckbox && candidate.visible) return 'visible checkbox challenge';
583-
if (candidate.visible && candidate.challengeFrame && candidate.frameVisible !== false) return 'visible challenge frame';
586+
if (candidate.visible && candidate.activeChallengeFrame && candidate.frameVisible !== false) return 'visible challenge frame';
584587
if (candidate.visible) return 'visible CAPTCHA widget';
585588
if (candidate.challengeFrame && candidate.frameVisible !== false) return 'challenge frame candidate';
586589
return 'only detected CAPTCHA candidate';
@@ -627,6 +630,29 @@ export function detectCaptchaCandidatesInPage(scope = null) {
627630
return null;
628631
}
629632
};
633+
// Page-serialized counterpart of captchaActiveChallengeFrameVendor in
634+
// captcha-gate.js. Only challenge routes, never checkbox routes, may arm it.
635+
const activeChallengeFrameVendor = (urlStr) => {
636+
try {
637+
const parsed = new URL(String(urlStr || ''), frameUrl || 'https://dummy.host');
638+
const host = parsed.hostname.toLowerCase();
639+
const path = parsed.pathname.toLowerCase();
640+
if (
641+
(/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host))
642+
&& /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path)
643+
) return 'recaptcha';
644+
if (/(^|\.)hcaptcha\.com$/.test(host)) {
645+
const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame');
646+
if (frame === 'challenge') return 'hcaptcha';
647+
}
648+
if (
649+
/(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host)
650+
&& /\/fc\/gc(?:\/|$)/.test(path)
651+
) return 'arkose';
652+
} catch (_) {}
653+
return '';
654+
};
655+
const documentChallengeVendor = activeChallengeFrameVendor(frameUrl);
630656
const visibleElement = (element) => {
631657
if (!element) return false;
632658
try {
@@ -714,7 +740,9 @@ export function detectCaptchaCandidatesInPage(scope = null) {
714740
candidates.push({
715741
...serializableCandidate,
716742
frameUrl,
717-
challengeFrame,
743+
challengeFrame: candidate.challengeFrame === true || challengeFrame,
744+
activeChallengeFrame: candidate.activeChallengeFrame === true
745+
|| !!documentChallengeVendor,
718746
responseField,
719747
responseTokenPresent: candidate.responseTokenPresent === true
720748
|| alsoResponseTokenPresent === true,
@@ -873,16 +901,21 @@ export function detectCaptchaCandidatesInPage(scope = null) {
873901
const recaptchaFrames = iframeUrls.filter(({ url }) =>
874902
/recaptcha\/(api2|enterprise)\/anchor/i.test(url)
875903
);
904+
const recaptchaChallengeFrames = iframeUrls.filter(({ url }) =>
905+
activeChallengeFrameVendor(url) === 'recaptcha'
906+
);
876907

877908
for (const { element, url } of iframeUrls) {
878909
if (/hcaptcha\.com/i.test(url)) {
879910
const websiteKey = urlParam(url, 'sitekey');
880911
if (websiteKey) {
912+
const activeChallengeFrame = activeChallengeFrameVendor(url) === 'hcaptcha';
881913
add({
882914
type: 'hcaptcha',
883915
websiteKey,
884916
visible: visibleElement(element),
885-
normalCheckbox: visibleElement(element),
917+
normalCheckbox: visibleElement(element) && !activeChallengeFrame,
918+
activeChallengeFrame,
886919
...responseFieldIdentity(
887920
element,
888921
'h-captcha-response',
@@ -919,10 +952,31 @@ export function detectCaptchaCandidatesInPage(scope = null) {
919952
}
920953
continue;
921954
}
922-
if (!/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
955+
const recaptchaChallengeFrame = activeChallengeFrameVendor(url) === 'recaptcha';
956+
if (!recaptchaChallengeFrame && !/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue;
923957
const websiteKey = urlParam(url, 'k');
924958
if (!websiteKey) continue;
925959
const isEnterprise = /recaptcha\/enterprise/i.test(url);
960+
if (recaptchaChallengeFrame) {
961+
const challengeIndex = recaptchaChallengeFrames.findIndex(frame => frame.element === element);
962+
add({
963+
type: isEnterprise ? 'recaptcha_v2_enterprise' : 'recaptcha_v2',
964+
websiteKey,
965+
isInvisible: false,
966+
isEnterprise,
967+
visible: visibleElement(element),
968+
normalCheckbox: false,
969+
activeChallengeFrame: true,
970+
...responseFieldIdentity(
971+
element,
972+
'g-recaptcha-response',
973+
challengeIndex,
974+
recaptchaChallengeFrames.length,
975+
),
976+
detectedVia: 'url',
977+
}, element);
978+
continue;
979+
}
926980
const isInvisible = urlParam(url, 'size') === 'invisible';
927981
const matchingV3Script = scriptUrls.find(scriptUrl => urlParam(scriptUrl, 'render') === websiteKey) || null;
928982
const isV3 = !!matchingV3Script;
@@ -994,13 +1048,15 @@ export function detectCaptchaCandidatesInPage(scope = null) {
9941048
try { url = String(element.src || ''); } catch (_) {}
9951049
try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch (_) {}
9961050
try { name = String(element.name || element.getAttribute?.('name') || ''); } catch (_) {}
1051+
const activeChallengeFrame = !!activeChallengeFrameVendor(loadedUrl || url);
9971052
return {
9981053
index,
9991054
url,
10001055
loadedUrl,
10011056
name,
10021057
visible: visibleElement(element),
10031058
dialogAssociated: elementInChallengeDialog(element),
1059+
activeChallengeFrame,
10041060
};
10051061
});
10061062
let frameName = '';

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

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,42 @@ export function detectChallengeDialogInPage(options = null) {
134134
return false;
135135
}
136136
};
137+
// This detector is serialized into the page, so it cannot call the exported
138+
// classifier below. Keep the vendor-specific routes aligned with it.
139+
const activeFrameVendor = (value) => {
140+
try {
141+
const parsed = new URL(String(value || ''));
142+
const host = parsed.hostname.toLowerCase();
143+
const path = parsed.pathname.toLowerCase();
144+
if (
145+
(/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host))
146+
&& /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path)
147+
) return 'recaptcha';
148+
if (/(^|\.)hcaptcha\.com$/.test(host)) {
149+
const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame');
150+
if (frame === 'challenge') return 'hcaptcha';
151+
}
152+
if (
153+
/(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host)
154+
&& /\/fc\/gc(?:\/|$)/.test(path)
155+
) return 'arkose';
156+
} catch {}
157+
return '';
158+
};
137159
const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => {
138160
let loadedUrl = '';
139161
try {
140162
loadedUrl = String(element.contentWindow?.location?.href || '');
141163
} catch {}
164+
const url = String(element.getAttribute?.('src') || element.src || '');
165+
const activeChallengeVendor = activeFrameVendor(loadedUrl || url);
142166
return {
143167
index,
144-
url: String(element.getAttribute?.('src') || element.src || ''),
168+
url,
145169
loadedUrl,
146170
name: String(element.getAttribute?.('name') || element.name || ''),
147171
visible: visible(element),
172+
...(activeChallengeVendor ? { activeChallengeVendor } : {}),
148173
};
149174
});
150175
// A challenge dialog that exists in the DOM but is hidden or off-viewport
@@ -250,6 +275,20 @@ export function detectChallengeDialogInPage(options = null) {
250275
if (label) return finish({ label });
251276
}
252277
}
278+
const activeChallengeFrame = childFrames.find(frame =>
279+
frame.visible === true && frame.activeChallengeVendor
280+
);
281+
if (activeChallengeFrame) {
282+
const vendorLabel = activeChallengeFrame.activeChallengeVendor === 'recaptcha'
283+
? 'reCAPTCHA'
284+
: activeChallengeFrame.activeChallengeVendor === 'hcaptcha'
285+
? 'hCaptcha'
286+
: 'Arkose';
287+
return finish({
288+
label: `Visible ${vendorLabel} challenge frame`,
289+
languageNeutralFrame: true,
290+
});
291+
}
253292
return finish(null);
254293
}
255294

@@ -302,7 +341,14 @@ export function buildCaptchaDiagnostics({
302341
} = {}) {
303342
const rows = [];
304343
const seen = new Set();
305-
const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => {
344+
const addFrame = ({
345+
frameId = null,
346+
parentFrameId = null,
347+
frameUrl = '',
348+
source,
349+
visible = null,
350+
activeChallengeFrame = false,
351+
}) => {
306352
const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl);
307353
if (!sanitizedUrl) return;
308354
const vendor = captchaVendorFromUrl(frameUrl);
@@ -315,6 +361,7 @@ export function buildCaptchaDiagnostics({
315361
frameUrl: sanitizedUrl,
316362
vendor,
317363
source,
364+
...(activeChallengeFrame ? { activeChallengeFrame: true } : {}),
318365
...(typeof visible === 'boolean' ? { visible } : {}),
319366
});
320367
};
@@ -338,6 +385,7 @@ export function buildCaptchaDiagnostics({
338385
frameUrl: child?.loadedUrl || child?.url,
339386
source: 'embedded',
340387
visible: child?.visible,
388+
activeChallengeFrame: child?.activeChallengeFrame === true,
341389
});
342390
}
343391
}
@@ -347,6 +395,7 @@ export function buildCaptchaDiagnostics({
347395
frameUrl: candidate?.frameUrl,
348396
source: 'candidate',
349397
visible: candidate?.visible,
398+
activeChallengeFrame: candidate?.activeChallengeFrame === true,
350399
});
351400
}
352401

0 commit comments

Comments
 (0)