diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 8cfe4e74a..b58c55ce5 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -3007,6 +3007,55 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (!pageUrl) { try { pageUrl = await this._currentUrl(tabId); } catch {} } + let detection = null; + let detectionFailed = false; + let failedDiagnostics = null; + let detectionAttempted = false; + const inspectCaptchaFrames = async () => { + if (detectionAttempted) return; + detectionAttempted = true; + try { + detection = await detectCaptcha(tabId); + } catch (error) { + detectionFailed = true; + failedDiagnostics = error?.captchaDiagnostics || null; + } + }; + let languageNeutralFrameTrigger = false; + const hasDialogSurface = toolResult.pageGate?.surface === 'dialog' + || /^\s*(?:dialog|alertdialog)(?=\s|$)/im.test(toolResult.pageContent); + if (!challenge && hasDialogSurface) { + await inspectCaptchaFrames(); + const selectedActiveFrame = detection?.selected?.activeChallengeFrame === true + && detection?.selected?.visible === true + && detection?.selected?.frameVisible !== false; + const diagnosticActiveFrame = (detection?.diagnostics?.frames || []).find(frame => + frame?.activeChallengeFrame === true && frame?.visible === true + ); + if (selectedActiveFrame || diagnosticActiveFrame) { + const vendor = diagnosticActiveFrame?.vendor + || (String(detection?.selected?.type || '').startsWith('recaptcha') + ? 'recaptcha' + : detection?.selected?.type || 'captcha'); + const vendorLabel = vendor === 'recaptcha' + ? 'reCAPTCHA' + : vendor === 'hcaptcha' + ? 'hCaptcha' + : vendor === 'arkose' + ? 'Arkose' + : 'CAPTCHA'; + challenge = detectChallengeDialog(`dialog ${JSON.stringify(`Visible ${vendorLabel} CAPTCHA challenge`)}`); + languageNeutralFrameTrigger = !!challenge; + if (selectedActiveFrame) { + observedChallengeFrameId = Number.isInteger(detection.selected.frameId) + ? detection.selected.frameId + : observedChallengeFrameId; + observedChallengeFrameUrl = String( + detection.selected.frameUrl || observedChallengeFrameUrl + ); + } + } + } const requestedPage = toolArgs?.page; const requestedMaxDepth = toolArgs?.maxDepth; const parsedMaxDepth = Number(requestedMaxDepth); @@ -3157,17 +3206,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return { gate: existing.publicGate, loopCheck }; } - let detection = null; - let detectionFailed = false; - let failedDiagnostics = null; - if (this.captchaSolverEnabled) { - try { - detection = await detectCaptcha(tabId); - } catch (error) { - detectionFailed = true; - failedDiagnostics = error?.captchaDiagnostics || null; - } - } + await inspectCaptchaFrames(); const diagnostics = detection?.diagnostics || failedDiagnostics || { vendors: [], candidateTypes: [], @@ -3181,8 +3220,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && frame?.visible === true )) .map(frame => frame.vendor))]; - const selectedCorrelated = detection?.selected?.dialogAssociated === true - && detection?.selected?.frameVisible !== false; + const selectedCorrelated = ( + detection?.selected?.dialogAssociated === true + || detection?.selected?.activeChallengeFrame === true + ) && detection?.selected?.frameVisible !== false; const supported = this.captchaSolverEnabled && !detectionFailed && !detection?.error @@ -3199,6 +3240,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}), ...(detection?.error ? { selectionFailed: true } : {}), ...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}), + ...(languageNeutralFrameTrigger ? { languageNeutralFrameTrigger: true } : {}), }; this._captchaGateStates.set(tabId, { key, diff --git a/src/chrome/src/agent/captcha-frame-runtime.js b/src/chrome/src/agent/captcha-frame-runtime.js index d73743a85..d481d0ef5 100644 --- a/src/chrome/src/agent/captcha-frame-runtime.js +++ b/src/chrome/src/agent/captcha-frame-runtime.js @@ -291,6 +291,7 @@ function candidateSummary(candidate) { visible: candidate?.visible === true, normalCheckbox: candidate?.normalCheckbox === true, challengeFrame: candidate?.challengeFrame === true, + activeChallengeFrame: candidate?.activeChallengeFrame === true, dialogAssociated: candidate?.dialogAssociated === true, frameVisible: candidate?.frameVisible !== false, isInvisible: candidate?.isInvisible === true, @@ -326,7 +327,7 @@ function candidateScore(candidate) { // Priority is tiered so no combination of secondary signals can make a // generic visible/background integration outrank an active challenge // frame or visible checkbox. - const activeChallengeFrame = candidate?.challengeFrame + const activeChallengeFrame = candidate?.activeChallengeFrame && candidate?.visible === true && candidate?.frameVisible !== false; const primary = (candidate?.normalCheckbox && candidate?.visible) || activeChallengeFrame; @@ -406,6 +407,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) { visible: previous.visible === true || candidate.visible === true, normalCheckbox: previous.normalCheckbox === true || candidate.normalCheckbox === true, challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true, + activeChallengeFrame: previous.activeChallengeFrame === true + || candidate.activeChallengeFrame === true, dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true, responseField: previous.responseField === true || candidate.responseField === true, responseTokenPresent: previous.responseTokenPresent === true @@ -580,7 +583,7 @@ function selectedReason(candidate, constraints) { if (constraints.frameUrl) return 'exact frameUrl match'; if (constraints.websiteKey) return 'exact websiteKey match'; if (candidate.normalCheckbox && candidate.visible) return 'visible checkbox challenge'; - if (candidate.visible && candidate.challengeFrame && candidate.frameVisible !== false) return 'visible challenge frame'; + if (candidate.visible && candidate.activeChallengeFrame && candidate.frameVisible !== false) return 'visible challenge frame'; if (candidate.visible) return 'visible CAPTCHA widget'; if (candidate.challengeFrame && candidate.frameVisible !== false) return 'challenge frame candidate'; return 'only detected CAPTCHA candidate'; @@ -627,6 +630,29 @@ export function detectCaptchaCandidatesInPage(scope = null) { return null; } }; + // Page-serialized counterpart of captchaActiveChallengeFrameVendor in + // captcha-gate.js. Only challenge routes, never checkbox routes, may arm it. + const activeChallengeFrameVendor = (urlStr) => { + try { + const parsed = new URL(String(urlStr || ''), frameUrl || 'https://dummy.host'); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if ( + (/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host)) + && /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path) + ) return 'recaptcha'; + if (/(^|\.)hcaptcha\.com$/.test(host)) { + const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame'); + if (frame === 'challenge') return 'hcaptcha'; + } + if ( + /(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host) + && /\/fc\/gc(?:\/|$)/.test(path) + ) return 'arkose'; + } catch (_) {} + return ''; + }; + const documentChallengeVendor = activeChallengeFrameVendor(frameUrl); const visibleElement = (element) => { if (!element) return false; try { @@ -714,7 +740,9 @@ export function detectCaptchaCandidatesInPage(scope = null) { candidates.push({ ...serializableCandidate, frameUrl, - challengeFrame, + challengeFrame: candidate.challengeFrame === true || challengeFrame, + activeChallengeFrame: candidate.activeChallengeFrame === true + || !!documentChallengeVendor, responseField, responseTokenPresent: candidate.responseTokenPresent === true || alsoResponseTokenPresent === true, @@ -873,16 +901,21 @@ export function detectCaptchaCandidatesInPage(scope = null) { const recaptchaFrames = iframeUrls.filter(({ url }) => /recaptcha\/(api2|enterprise)\/anchor/i.test(url) ); + const recaptchaChallengeFrames = iframeUrls.filter(({ url }) => + activeChallengeFrameVendor(url) === 'recaptcha' + ); for (const { element, url } of iframeUrls) { if (/hcaptcha\.com/i.test(url)) { const websiteKey = urlParam(url, 'sitekey'); if (websiteKey) { + const activeChallengeFrame = activeChallengeFrameVendor(url) === 'hcaptcha'; add({ type: 'hcaptcha', websiteKey, visible: visibleElement(element), - normalCheckbox: visibleElement(element), + normalCheckbox: visibleElement(element) && !activeChallengeFrame, + activeChallengeFrame, ...responseFieldIdentity( element, 'h-captcha-response', @@ -919,10 +952,31 @@ export function detectCaptchaCandidatesInPage(scope = null) { } continue; } - if (!/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue; + const recaptchaChallengeFrame = activeChallengeFrameVendor(url) === 'recaptcha'; + if (!recaptchaChallengeFrame && !/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue; const websiteKey = urlParam(url, 'k'); if (!websiteKey) continue; const isEnterprise = /recaptcha\/enterprise/i.test(url); + if (recaptchaChallengeFrame) { + const challengeIndex = recaptchaChallengeFrames.findIndex(frame => frame.element === element); + add({ + type: isEnterprise ? 'recaptcha_v2_enterprise' : 'recaptcha_v2', + websiteKey, + isInvisible: false, + isEnterprise, + visible: visibleElement(element), + normalCheckbox: false, + activeChallengeFrame: true, + ...responseFieldIdentity( + element, + 'g-recaptcha-response', + challengeIndex, + recaptchaChallengeFrames.length, + ), + detectedVia: 'url', + }, element); + continue; + } const isInvisible = urlParam(url, 'size') === 'invisible'; const matchingV3Script = scriptUrls.find(scriptUrl => urlParam(scriptUrl, 'render') === websiteKey) || null; const isV3 = !!matchingV3Script; @@ -994,6 +1048,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { try { url = String(element.src || ''); } catch (_) {} try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch (_) {} try { name = String(element.name || element.getAttribute?.('name') || ''); } catch (_) {} + const activeChallengeFrame = !!activeChallengeFrameVendor(loadedUrl || url); return { index, url, @@ -1001,6 +1056,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { name, visible: visibleElement(element), dialogAssociated: elementInChallengeDialog(element), + activeChallengeFrame, }; }); let frameName = ''; diff --git a/src/chrome/src/agent/captcha-gate.js b/src/chrome/src/agent/captcha-gate.js index d2e2cfa3f..a9b430d8c 100644 --- a/src/chrome/src/agent/captcha-gate.js +++ b/src/chrome/src/agent/captcha-gate.js @@ -134,17 +134,42 @@ export function detectChallengeDialogInPage(options = null) { return false; } }; + // This detector is serialized into the page, so it cannot call the exported + // classifier below. Keep the vendor-specific routes aligned with it. + const activeFrameVendor = (value) => { + try { + const parsed = new URL(String(value || '')); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if ( + (/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host)) + && /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path) + ) return 'recaptcha'; + if (/(^|\.)hcaptcha\.com$/.test(host)) { + const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame'); + if (frame === 'challenge') return 'hcaptcha'; + } + if ( + /(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host) + && /\/fc\/gc(?:\/|$)/.test(path) + ) return 'arkose'; + } catch {} + return ''; + }; const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => { let loadedUrl = ''; try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch {} + const url = String(element.getAttribute?.('src') || element.src || ''); + const activeChallengeVendor = activeFrameVendor(loadedUrl || url); return { index, - url: String(element.getAttribute?.('src') || element.src || ''), + url, loadedUrl, name: String(element.getAttribute?.('name') || element.name || ''), visible: visible(element), + ...(activeChallengeVendor ? { activeChallengeVendor } : {}), }; }); // A challenge dialog that exists in the DOM but is hidden or off-viewport @@ -250,6 +275,20 @@ export function detectChallengeDialogInPage(options = null) { if (label) return finish({ label }); } } + const activeChallengeFrame = childFrames.find(frame => + frame.visible === true && frame.activeChallengeVendor + ); + if (activeChallengeFrame) { + const vendorLabel = activeChallengeFrame.activeChallengeVendor === 'recaptcha' + ? 'reCAPTCHA' + : activeChallengeFrame.activeChallengeVendor === 'hcaptcha' + ? 'hCaptcha' + : 'Arkose'; + return finish({ + label: `Visible ${vendorLabel} challenge frame`, + languageNeutralFrame: true, + }); + } return finish(null); } @@ -302,7 +341,14 @@ export function buildCaptchaDiagnostics({ } = {}) { const rows = []; const seen = new Set(); - const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => { + const addFrame = ({ + frameId = null, + parentFrameId = null, + frameUrl = '', + source, + visible = null, + activeChallengeFrame = false, + }) => { const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl); if (!sanitizedUrl) return; const vendor = captchaVendorFromUrl(frameUrl); @@ -315,6 +361,7 @@ export function buildCaptchaDiagnostics({ frameUrl: sanitizedUrl, vendor, source, + ...(activeChallengeFrame ? { activeChallengeFrame: true } : {}), ...(typeof visible === 'boolean' ? { visible } : {}), }); }; @@ -338,6 +385,7 @@ export function buildCaptchaDiagnostics({ frameUrl: child?.loadedUrl || child?.url, source: 'embedded', visible: child?.visible, + activeChallengeFrame: child?.activeChallengeFrame === true, }); } } @@ -347,6 +395,7 @@ export function buildCaptchaDiagnostics({ frameUrl: candidate?.frameUrl, source: 'candidate', visible: candidate?.visible, + activeChallengeFrame: candidate?.activeChallengeFrame === true, }); } diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 32f5b7068..47c3963f7 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -2632,6 +2632,55 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d if (!pageUrl) { try { pageUrl = await this._currentUrl(tabId); } catch {} } + let detection = null; + let detectionFailed = false; + let failedDiagnostics = null; + let detectionAttempted = false; + const inspectCaptchaFrames = async () => { + if (detectionAttempted) return; + detectionAttempted = true; + try { + detection = await detectCaptcha(tabId); + } catch (error) { + detectionFailed = true; + failedDiagnostics = error?.captchaDiagnostics || null; + } + }; + let languageNeutralFrameTrigger = false; + const hasDialogSurface = toolResult.pageGate?.surface === 'dialog' + || /^\s*(?:dialog|alertdialog)(?=\s|$)/im.test(toolResult.pageContent); + if (!challenge && hasDialogSurface) { + await inspectCaptchaFrames(); + const selectedActiveFrame = detection?.selected?.activeChallengeFrame === true + && detection?.selected?.visible === true + && detection?.selected?.frameVisible !== false; + const diagnosticActiveFrame = (detection?.diagnostics?.frames || []).find(frame => + frame?.activeChallengeFrame === true && frame?.visible === true + ); + if (selectedActiveFrame || diagnosticActiveFrame) { + const vendor = diagnosticActiveFrame?.vendor + || (String(detection?.selected?.type || '').startsWith('recaptcha') + ? 'recaptcha' + : detection?.selected?.type || 'captcha'); + const vendorLabel = vendor === 'recaptcha' + ? 'reCAPTCHA' + : vendor === 'hcaptcha' + ? 'hCaptcha' + : vendor === 'arkose' + ? 'Arkose' + : 'CAPTCHA'; + challenge = detectChallengeDialog(`dialog ${JSON.stringify(`Visible ${vendorLabel} CAPTCHA challenge`)}`); + languageNeutralFrameTrigger = !!challenge; + if (selectedActiveFrame) { + observedChallengeFrameId = Number.isInteger(detection.selected.frameId) + ? detection.selected.frameId + : observedChallengeFrameId; + observedChallengeFrameUrl = String( + detection.selected.frameUrl || observedChallengeFrameUrl + ); + } + } + } const requestedPage = toolArgs?.page; const requestedMaxDepth = toolArgs?.maxDepth; const parsedMaxDepth = Number(requestedMaxDepth); @@ -2782,17 +2831,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d return { gate: existing.publicGate, loopCheck }; } - let detection = null; - let detectionFailed = false; - let failedDiagnostics = null; - if (this.captchaSolverEnabled) { - try { - detection = await detectCaptcha(tabId); - } catch (error) { - detectionFailed = true; - failedDiagnostics = error?.captchaDiagnostics || null; - } - } + await inspectCaptchaFrames(); const diagnostics = detection?.diagnostics || failedDiagnostics || { vendors: [], candidateTypes: [], @@ -2806,8 +2845,10 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d && frame?.visible === true )) .map(frame => frame.vendor))]; - const selectedCorrelated = detection?.selected?.dialogAssociated === true - && detection?.selected?.frameVisible !== false; + const selectedCorrelated = ( + detection?.selected?.dialogAssociated === true + || detection?.selected?.activeChallengeFrame === true + ) && detection?.selected?.frameVisible !== false; const supported = this.captchaSolverEnabled && !detectionFailed && !detection?.error @@ -2824,6 +2865,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ...(!this.captchaSolverEnabled ? { solverDisabled: true } : {}), ...(detection?.error ? { selectionFailed: true } : {}), ...(detection?.selected && !selectedCorrelated ? { candidateNotCorrelated: true } : {}), + ...(languageNeutralFrameTrigger ? { languageNeutralFrameTrigger: true } : {}), }; this._captchaGateStates.set(tabId, { key, diff --git a/src/firefox/src/agent/captcha-frame-runtime.js b/src/firefox/src/agent/captcha-frame-runtime.js index d73743a85..d481d0ef5 100644 --- a/src/firefox/src/agent/captcha-frame-runtime.js +++ b/src/firefox/src/agent/captcha-frame-runtime.js @@ -291,6 +291,7 @@ function candidateSummary(candidate) { visible: candidate?.visible === true, normalCheckbox: candidate?.normalCheckbox === true, challengeFrame: candidate?.challengeFrame === true, + activeChallengeFrame: candidate?.activeChallengeFrame === true, dialogAssociated: candidate?.dialogAssociated === true, frameVisible: candidate?.frameVisible !== false, isInvisible: candidate?.isInvisible === true, @@ -326,7 +327,7 @@ function candidateScore(candidate) { // Priority is tiered so no combination of secondary signals can make a // generic visible/background integration outrank an active challenge // frame or visible checkbox. - const activeChallengeFrame = candidate?.challengeFrame + const activeChallengeFrame = candidate?.activeChallengeFrame && candidate?.visible === true && candidate?.frameVisible !== false; const primary = (candidate?.normalCheckbox && candidate?.visible) || activeChallengeFrame; @@ -406,6 +407,8 @@ export function selectCaptchaCandidate(candidates, constraints = {}) { visible: previous.visible === true || candidate.visible === true, normalCheckbox: previous.normalCheckbox === true || candidate.normalCheckbox === true, challengeFrame: previous.challengeFrame === true || candidate.challengeFrame === true, + activeChallengeFrame: previous.activeChallengeFrame === true + || candidate.activeChallengeFrame === true, dialogAssociated: previous.dialogAssociated === true || candidate.dialogAssociated === true, responseField: previous.responseField === true || candidate.responseField === true, responseTokenPresent: previous.responseTokenPresent === true @@ -580,7 +583,7 @@ function selectedReason(candidate, constraints) { if (constraints.frameUrl) return 'exact frameUrl match'; if (constraints.websiteKey) return 'exact websiteKey match'; if (candidate.normalCheckbox && candidate.visible) return 'visible checkbox challenge'; - if (candidate.visible && candidate.challengeFrame && candidate.frameVisible !== false) return 'visible challenge frame'; + if (candidate.visible && candidate.activeChallengeFrame && candidate.frameVisible !== false) return 'visible challenge frame'; if (candidate.visible) return 'visible CAPTCHA widget'; if (candidate.challengeFrame && candidate.frameVisible !== false) return 'challenge frame candidate'; return 'only detected CAPTCHA candidate'; @@ -627,6 +630,29 @@ export function detectCaptchaCandidatesInPage(scope = null) { return null; } }; + // Page-serialized counterpart of captchaActiveChallengeFrameVendor in + // captcha-gate.js. Only challenge routes, never checkbox routes, may arm it. + const activeChallengeFrameVendor = (urlStr) => { + try { + const parsed = new URL(String(urlStr || ''), frameUrl || 'https://dummy.host'); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if ( + (/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host)) + && /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path) + ) return 'recaptcha'; + if (/(^|\.)hcaptcha\.com$/.test(host)) { + const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame'); + if (frame === 'challenge') return 'hcaptcha'; + } + if ( + /(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host) + && /\/fc\/gc(?:\/|$)/.test(path) + ) return 'arkose'; + } catch (_) {} + return ''; + }; + const documentChallengeVendor = activeChallengeFrameVendor(frameUrl); const visibleElement = (element) => { if (!element) return false; try { @@ -714,7 +740,9 @@ export function detectCaptchaCandidatesInPage(scope = null) { candidates.push({ ...serializableCandidate, frameUrl, - challengeFrame, + challengeFrame: candidate.challengeFrame === true || challengeFrame, + activeChallengeFrame: candidate.activeChallengeFrame === true + || !!documentChallengeVendor, responseField, responseTokenPresent: candidate.responseTokenPresent === true || alsoResponseTokenPresent === true, @@ -873,16 +901,21 @@ export function detectCaptchaCandidatesInPage(scope = null) { const recaptchaFrames = iframeUrls.filter(({ url }) => /recaptcha\/(api2|enterprise)\/anchor/i.test(url) ); + const recaptchaChallengeFrames = iframeUrls.filter(({ url }) => + activeChallengeFrameVendor(url) === 'recaptcha' + ); for (const { element, url } of iframeUrls) { if (/hcaptcha\.com/i.test(url)) { const websiteKey = urlParam(url, 'sitekey'); if (websiteKey) { + const activeChallengeFrame = activeChallengeFrameVendor(url) === 'hcaptcha'; add({ type: 'hcaptcha', websiteKey, visible: visibleElement(element), - normalCheckbox: visibleElement(element), + normalCheckbox: visibleElement(element) && !activeChallengeFrame, + activeChallengeFrame, ...responseFieldIdentity( element, 'h-captcha-response', @@ -919,10 +952,31 @@ export function detectCaptchaCandidatesInPage(scope = null) { } continue; } - if (!/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue; + const recaptchaChallengeFrame = activeChallengeFrameVendor(url) === 'recaptcha'; + if (!recaptchaChallengeFrame && !/recaptcha\/(api2|enterprise)\/anchor/i.test(url)) continue; const websiteKey = urlParam(url, 'k'); if (!websiteKey) continue; const isEnterprise = /recaptcha\/enterprise/i.test(url); + if (recaptchaChallengeFrame) { + const challengeIndex = recaptchaChallengeFrames.findIndex(frame => frame.element === element); + add({ + type: isEnterprise ? 'recaptcha_v2_enterprise' : 'recaptcha_v2', + websiteKey, + isInvisible: false, + isEnterprise, + visible: visibleElement(element), + normalCheckbox: false, + activeChallengeFrame: true, + ...responseFieldIdentity( + element, + 'g-recaptcha-response', + challengeIndex, + recaptchaChallengeFrames.length, + ), + detectedVia: 'url', + }, element); + continue; + } const isInvisible = urlParam(url, 'size') === 'invisible'; const matchingV3Script = scriptUrls.find(scriptUrl => urlParam(scriptUrl, 'render') === websiteKey) || null; const isV3 = !!matchingV3Script; @@ -994,6 +1048,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { try { url = String(element.src || ''); } catch (_) {} try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch (_) {} try { name = String(element.name || element.getAttribute?.('name') || ''); } catch (_) {} + const activeChallengeFrame = !!activeChallengeFrameVendor(loadedUrl || url); return { index, url, @@ -1001,6 +1056,7 @@ export function detectCaptchaCandidatesInPage(scope = null) { name, visible: visibleElement(element), dialogAssociated: elementInChallengeDialog(element), + activeChallengeFrame, }; }); let frameName = ''; diff --git a/src/firefox/src/agent/captcha-gate.js b/src/firefox/src/agent/captcha-gate.js index d2e2cfa3f..a9b430d8c 100644 --- a/src/firefox/src/agent/captcha-gate.js +++ b/src/firefox/src/agent/captcha-gate.js @@ -134,17 +134,42 @@ export function detectChallengeDialogInPage(options = null) { return false; } }; + // This detector is serialized into the page, so it cannot call the exported + // classifier below. Keep the vendor-specific routes aligned with it. + const activeFrameVendor = (value) => { + try { + const parsed = new URL(String(value || '')); + const host = parsed.hostname.toLowerCase(); + const path = parsed.pathname.toLowerCase(); + if ( + (/(^|\.)google\.com$/.test(host) || /(^|\.)recaptcha\.net$/.test(host)) + && /\/recaptcha\/(?:api2|enterprise)\/bframe(?:\/|$)/.test(path) + ) return 'recaptcha'; + if (/(^|\.)hcaptcha\.com$/.test(host)) { + const frame = new URLSearchParams(String(parsed.hash || '').replace(/^#/, '')).get('frame'); + if (frame === 'challenge') return 'hcaptcha'; + } + if ( + /(^|\.)(?:arkoselabs|funcaptcha)\.com$/.test(host) + && /\/fc\/gc(?:\/|$)/.test(path) + ) return 'arkose'; + } catch {} + return ''; + }; const childFrames = Array.from(document.querySelectorAll('iframe')).map((element, index) => { let loadedUrl = ''; try { loadedUrl = String(element.contentWindow?.location?.href || ''); } catch {} + const url = String(element.getAttribute?.('src') || element.src || ''); + const activeChallengeVendor = activeFrameVendor(loadedUrl || url); return { index, - url: String(element.getAttribute?.('src') || element.src || ''), + url, loadedUrl, name: String(element.getAttribute?.('name') || element.name || ''), visible: visible(element), + ...(activeChallengeVendor ? { activeChallengeVendor } : {}), }; }); // A challenge dialog that exists in the DOM but is hidden or off-viewport @@ -250,6 +275,20 @@ export function detectChallengeDialogInPage(options = null) { if (label) return finish({ label }); } } + const activeChallengeFrame = childFrames.find(frame => + frame.visible === true && frame.activeChallengeVendor + ); + if (activeChallengeFrame) { + const vendorLabel = activeChallengeFrame.activeChallengeVendor === 'recaptcha' + ? 'reCAPTCHA' + : activeChallengeFrame.activeChallengeVendor === 'hcaptcha' + ? 'hCaptcha' + : 'Arkose'; + return finish({ + label: `Visible ${vendorLabel} challenge frame`, + languageNeutralFrame: true, + }); + } return finish(null); } @@ -302,7 +341,14 @@ export function buildCaptchaDiagnostics({ } = {}) { const rows = []; const seen = new Set(); - const addFrame = ({ frameId = null, parentFrameId = null, frameUrl = '', source, visible = null }) => { + const addFrame = ({ + frameId = null, + parentFrameId = null, + frameUrl = '', + source, + visible = null, + activeChallengeFrame = false, + }) => { const sanitizedUrl = sanitizeCaptchaFrameUrl(frameUrl); if (!sanitizedUrl) return; const vendor = captchaVendorFromUrl(frameUrl); @@ -315,6 +361,7 @@ export function buildCaptchaDiagnostics({ frameUrl: sanitizedUrl, vendor, source, + ...(activeChallengeFrame ? { activeChallengeFrame: true } : {}), ...(typeof visible === 'boolean' ? { visible } : {}), }); }; @@ -338,6 +385,7 @@ export function buildCaptchaDiagnostics({ frameUrl: child?.loadedUrl || child?.url, source: 'embedded', visible: child?.visible, + activeChallengeFrame: child?.activeChallengeFrame === true, }); } } @@ -347,6 +395,7 @@ export function buildCaptchaDiagnostics({ frameUrl: candidate?.frameUrl, source: 'candidate', visible: candidate?.visible, + activeChallengeFrame: candidate?.activeChallengeFrame === true, }); } diff --git a/test/run.js b/test/run.js index 6ebdaa2c2..0ef691ee3 100644 --- a/test/run.js +++ b/test/run.js @@ -54419,6 +54419,185 @@ test('challenge-dialog routing detects supported widgets and diagnoses unsupport } }); +test('language-neutral CAPTCHA challenge frames arm the gate without matching dialog copy', async () => { + for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { + const supportedCases = [ + { + label: 'reCAPTCHA bframe', + status: 'solve_required', + selectedType: 'recaptcha_v2', + nodes: [ + captchaEl('div', { role: 'dialog', innerText: 'Sicherheitsüberprüfung' }, [ + captchaEl('h2', { textContent: 'Sicherheitsüberprüfung' }), + captchaEl('textarea', { + id: 'g-recaptcha-response-localized', + name: 'g-recaptcha-response', + }), + captchaEl('iframe', { + src: 'https://www.google.com/recaptcha/api2/bframe?k=LOCALIZED_RECAPTCHA_KEY', + }), + ]), + ], + }, + { + label: 'hCaptcha challenge frame', + status: 'solve_required', + selectedType: 'hcaptcha', + nodes: [ + captchaEl('div', { role: 'dialog', innerText: 'Güvenlik doğrulaması' }, [ + captchaEl('h2', { textContent: 'Güvenlik doğrulaması' }), + captchaEl('textarea', { + id: 'h-captcha-response-localized', + name: 'h-captcha-response', + }), + captchaEl('iframe', { + src: 'https://newassets.hcaptcha.com/captcha/v1/hcaptcha.html#frame=challenge&sitekey=LOCALIZED_HCAPTCHA_KEY', + }), + ]), + ], + }, + { + label: 'Arkose enforcement frame', + status: 'manual_required', + nodes: [ + captchaEl('div', { role: 'dialog', innerText: 'Vérification de sécurité' }, [ + captchaEl('h2', { textContent: 'Vérification de sécurité' }), + captchaEl('iframe', { + src: 'https://client-api.arkoselabs.com/fc/gc/?token=LOCALIZED_ARKOSE_TOKEN', + }), + ]), + ], + }, + ]; + + for (const example of supportedCases) { + await withCaptchaFakePage(build, example.nodes, async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const observed = await agent._observeCaptchaChallenge( + 1, + 'get_accessibility_tree', + { + pageContent: 'dialog "Sicherheitsüberprüfung" [ref_300]\n button "Weiter" [ref_301]', + }, + { filter: 'visible' }, + ); + assert.equal( + observed.gate?.status, + example.status, + `${build}: ${example.label} did not arm the localized challenge gate`, + ); + if (example.selectedType) { + assert.equal( + observed.gate?.selectedType, + example.selectedType, + `${build}: ${example.label} selected the wrong solver type`, + ); + } else { + assert.equal( + observed.gate?.unsupportedVendors?.includes('arkose'), + true, + `${build}: ${example.label} did not report the unsupported vendor`, + ); + } + assert.equal( + observed.gate?.languageNeutralFrameTrigger, + true, + `${build}: ${example.label} did not report the language-neutral trigger`, + ); + + const disabledAgent = new AgentClass({}); + disabledAgent.captchaSolverEnabled = false; + disabledAgent._currentUrl = async () => 'https://example.test/signup'; + const disabled = await disabledAgent._observeCaptchaChallenge( + 4, + 'get_accessibility_tree', + { pageContent: 'dialog "Sicherheitsüberprüfung" [ref_302]' }, + { filter: 'visible' }, + ); + assert.equal( + disabled.gate?.status, + 'manual_required', + `${build}: ${example.label} bypassed the gate when the solver was disabled`, + ); + assert.equal( + disabled.gate?.solverDisabled, + true, + `${build}: ${example.label} did not explain manual routing`, + ); + + const preflightAgent = new AgentClass({}); + preflightAgent.captchaSolverEnabled = true; + preflightAgent._currentUrl = async () => 'https://example.test/signup'; + const preflight = await preflightAgent._captchaMutationPreflight(2, 'click_ax'); + assert.equal( + preflight?.status, + example.status, + `${build}: ${example.label} did not block the first mutation`, + ); + }); + } + + const inactiveCases = [ + { + label: 'ordinary reCAPTCHA anchor', + node: captchaEl('iframe', { + src: 'https://www.google.com/recaptcha/api2/anchor?k=IDLE_RECAPTCHA_KEY', + }), + }, + { + label: 'hCaptcha checkbox frame', + node: captchaEl('iframe', { + src: 'https://newassets.hcaptcha.com/captcha/v1/hcaptcha.html#frame=checkbox&sitekey=IDLE_HCAPTCHA_KEY', + }), + }, + { + label: 'hidden reCAPTCHA challenge frame', + node: captchaEl('iframe', { + src: 'https://www.google.com/recaptcha/api2/bframe?k=HIDDEN_RECAPTCHA_KEY', + hidden: true, + }), + }, + { + label: 'generic application CAPTCHA route', + node: captchaEl('iframe', { + src: 'https://example.test/checkpoint/captcha/challenge', + }), + }, + { + label: 'spoofed reCAPTCHA vendor host', + node: captchaEl('iframe', { + src: 'https://google.com.example.test/recaptcha/api2/bframe?k=SPOOFED_KEY', + }), + }, + ]; + for (const example of inactiveCases) { + await withCaptchaFakePage(build, [ + captchaEl('div', { role: 'dialog', innerText: 'Sicherheitsüberprüfung' }, [ + captchaEl('h2', { textContent: 'Sicherheitsüberprüfung' }), + example.node, + ]), + ], async () => { + const agent = new AgentClass({}); + agent.captchaSolverEnabled = true; + agent._currentUrl = async () => 'https://example.test/signup'; + const observed = await agent._observeCaptchaChallenge( + 3, + 'get_accessibility_tree', + { pageContent: 'dialog "Sicherheitsüberprüfung" [ref_310]' }, + { filter: 'visible' }, + ); + assert.equal( + observed.gate, + null, + `${build}: ${example.label} armed a language-neutral challenge gate`, + ); + }); + } + } +}); + test('enabled CAPTCHA gate performs a read-only dialog preflight before the first mutation', async () => { for (const [build, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) { const challengeLabel = 'Complete "Security verification" now'; @@ -55494,6 +55673,7 @@ test('captcha frame visibility propagation demotes descendants of hidden embeddi visible: true, normalCheckbox: true, challengeFrame: true, + activeChallengeFrame: true, detectedVia: 'host', }; const navigationFrames = [ @@ -55793,6 +55973,7 @@ test('captcha candidate ranking fails closed on ties and honors exact targeting' websiteKey: 'KEY_HIDDEN', visible: false, challengeFrame: true, + activeChallengeFrame: true, responseField: true, detectedVia: 'script', };