From 62bb9995ee4f0aea19e1e9324a03d0188d7780f1 Mon Sep 17 00:00:00 2001 From: liwei9745 <36831896+liwei9745@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:31:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(captcha):=20=E5=85=BC=E5=AE=B9=E6=96=B0?= =?UTF-8?q?=E7=89=88=20Flow=20=E5=89=8D=E7=AB=AF=E7=9A=84=20reCAPTCHA=20?= =?UTF-8?q?=E5=8F=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新版 Flow 前端(boq_labs-ai-sandbox-frontend_20261105.10_p0 起)不再调用 grecaptcha.enterprise.execute.bind(),同时启用了 Trusted Types CSP,导致 有头浏览器打码(captcha_method=browser)在取码阶段持续失败,最终报 "Failed to obtain reCAPTCHA token"。 具体改动: - 取码脚本新增直接执行 enterprise.execute(siteKey, {action}) 的路径。 捕获闭包失败时不再渲染到 document.documentElement(该节点必然非空, 会抛 "reCAPTCHA placeholder element must be empty"),改为渲染到 新建的空容器兜底。 - 就绪检测改用 page.evaluate 轮询,替换 page.wait_for_function,避免 Trusted Types 下字符串 eval 抛 "violates Trusted Type assignment requirements"。 - 脚本注入改用 trustedTypes.createPolicy(...).createScriptURL(...), 不再使用在 Trusted Types 下必然失败的 add_script_tag。 - 修复 page.evaluate 传入多个位置参数导致的 TypeError,此前页面内 注入兜底路径从未真正执行。 - 同步修复自定义打码(分数测试)路径,并补充“新版前端不 bind”场景的 回归测试。 --- src/services/browser_captcha.py | 197 +++++++++++++++++------------- src/services/recaptcha_runtime.py | 195 +++++++++++++++-------------- tests/test_recaptcha_runtime.py | 52 ++++++++ 3 files changed, 267 insertions(+), 177 deletions(-) diff --git a/src/services/browser_captcha.py b/src/services/browser_captcha.py index 35950368..27078fd7 100644 --- a/src/services/browser_captcha.py +++ b/src/services/browser_captcha.py @@ -2582,40 +2582,25 @@ def handle_request_failed(request): ) await asyncio.sleep(warmup_seconds) - try: - await page.wait_for_function(wait_expression, timeout=15000) - except Exception as e: + if not await self._poll_enterprise_ready(page, 15000, wait_expression): debug_logger.log_warning( - f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 未就绪,尝试补注入脚本: {type(e).__name__}: {str(e)[:200]}" + f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 未就绪,尝试补注入脚本" ) - try: - await page.evaluate( - f""" - (primaryUrl, secondaryUrl) => {{ - const existing = Array.from(document.scripts || []).some((script) => {{ - const src = script?.src || ""; - return src.includes('/recaptcha/'); - }}); - if (existing) return; - const urls = [primaryUrl, secondaryUrl]; - const loadScript = (index) => {{ - if (index >= urls.length) return; - const script = document.createElement('script'); - script.src = urls[index]; - script.async = true; - script.onerror = () => loadScript(index + 1); - document.head.appendChild(script); - }}; - loadScript(0); - }} - """, - f"{primary_host}/{script_path}?render={website_key}", - f"{secondary_host}/{script_path}?render={website_key}", - ) - await page.wait_for_function(wait_expression, timeout=15000) - except Exception as inject_error: + injected = False + for script_host in (primary_host, secondary_host): + script_url = f"{script_host}/{script_path}?render={website_key}" + if await self._inject_recaptcha_script( + page, script_url, "/recaptcha/" + ): + injected = True + if await self._poll_enterprise_ready( + page, 15000, wait_expression + ): + injected = True + break + if not injected: debug_logger.log_warning( - f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 最终未就绪: {type(inject_error).__name__}: {str(inject_error)[:200]}" + f"[BrowserCaptcha] Token-{self.token_id} 自定义 grecaptcha 最终未就绪" ) return None @@ -2706,6 +2691,86 @@ def get_last_fingerprint(self) -> Optional[Dict[str, Any]]: return None return dict(self._last_fingerprint) + ENTERPRISE_READY_EXPRESSION = ( + "() => typeof grecaptcha !== 'undefined' && " + "typeof grecaptcha.enterprise !== 'undefined' && " + "typeof grecaptcha.enterprise.execute === 'function'" + ) + + # 新版 Flow 页面启用了 Trusted Types + # (`require-trusted-types-for 'script'`),直接给 script.src 赋值会抛 + # "This document requires 'TrustedScriptURL' assignment"。 + # 这里通过页面内创建的 Trusted Types policy 完成注入,避免使用 + # Playwright 的 add_script_tag(它在 Trusted Types 下必然失败)。 + INJECT_RECAPTCHA_SCRIPT_JS = r""" + ({url, marker}) => { + const scripts = Array.from(document.scripts || []); + const existing = scripts.some((script) => { + const src = script && script.src ? String(script.src) : ''; + return src.includes(marker); + }); + if (existing) return 'existing'; + let policy = null; + try { + if ( + window.trustedTypes && + typeof window.trustedTypes.createPolicy === 'function' + ) { + policy = window.trustedTypes.createPolicy( + 'flow2api-recaptcha-' + Math.random().toString(36).slice(2), + { createScriptURL: (value) => value } + ); + } + } catch (error) {} + const script = document.createElement('script'); + script.async = true; + try { + script.src = policy ? policy.createScriptURL(url) : url; + } catch (error) { + script.src = url; + } + (document.head || document.documentElement).appendChild(script); + return 'injected'; + } + """ + + async def _poll_enterprise_ready( + self, page, timeout_ms: int, expression: Optional[str] = None + ) -> bool: + """用 page.evaluate 轮询 grecaptcha 就绪状态。 + + 不使用 ``page.wait_for_function``:新版 Flow 的 Trusted Types CSP 会让 + 其中的字符串 eval 抛 + "Evaluating a string as JavaScript violates this document's Trusted Type + assignment requirements",导致就绪检测永远失败。 + """ + probe = expression or self.ENTERPRISE_READY_EXPRESSION + deadline = time.monotonic() + max(0.0, timeout_ms / 1000.0) + while True: + try: + if await page.evaluate(probe): + return True + except Exception: + pass + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.25) + + async def _inject_recaptcha_script(self, page, url: str, marker: str) -> bool: + """在页面内注入 reCAPTCHA 脚本(兼容 Trusted Types CSP)。""" + try: + await page.evaluate( + self.INJECT_RECAPTCHA_SCRIPT_JS, + {"url": url, "marker": marker}, + ) + return True + except Exception as e: + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} 页面内注入脚本失败: " + f"{type(e).__name__}: {str(e)[:150]}" + ) + return False + async def _wait_for_enterprise_ready( self, page, @@ -2715,69 +2780,27 @@ async def _wait_for_enterprise_ready( *, timeout_ms: int = 15000, context_label: str = "", + script_path: str = "recaptcha/enterprise.js", + marker: str = "/recaptcha/enterprise.js", ) -> bool: - """等待 grecaptcha.enterprise 就绪,必要时补注入 enterprise.js。""" - wait_expression = ( - "typeof grecaptcha !== 'undefined' && " - "typeof grecaptcha.enterprise !== 'undefined' && " - "typeof grecaptcha.enterprise.execute === 'function'" - ) + """等待 grecaptcha.enterprise 就绪,必要时补注入脚本。""" label = f"{context_label} " if context_label else "" - try: - await page.wait_for_function(wait_expression, timeout=timeout_ms) + if await self._poll_enterprise_ready(page, timeout_ms): return True - except Exception as e: - debug_logger.log_warning( - f"[BrowserCaptcha] Token-{self.token_id} {label}grecaptcha 未就绪,尝试补注入脚本: " - f"{type(e).__name__}: {str(e)[:200]}" - ) - try: - await page.evaluate( - """ - (primaryUrl, secondaryUrl) => { - const existing = Array.from(document.scripts || []).some((script) => { - const src = script?.src || ""; - return src.includes('/recaptcha/enterprise.js'); - }); - if (existing) return; - const urls = [primaryUrl, secondaryUrl]; - const loadScript = (index) => { - if (index >= urls.length) return; - const script = document.createElement('script'); - script.src = urls[index]; - script.async = true; - script.onerror = () => loadScript(index + 1); - document.head.appendChild(script); - }; - loadScript(0); - } - """, - f"{primary_host}/recaptcha/enterprise.js?render={website_key}", - f"{secondary_host}/recaptcha/enterprise.js?render={website_key}", - ) - await page.wait_for_function(wait_expression, timeout=timeout_ms) - return True - except Exception as inject_error: - debug_logger.log_warning( - f"[BrowserCaptcha] Token-{self.token_id} {label}页面内注入失败" - f"(新版 CSP 会拒绝动态 script),改用 Playwright add_script_tag: " - f"{type(inject_error).__name__}: {str(inject_error)[:150]}" - ) + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} {label}grecaptcha 未就绪,尝试补注入脚本" + ) - # 最终兜底:Playwright add_script_tag(配合 CSP 剥除 route 生效) for script_host in (primary_host, secondary_host): - try: - await page.add_script_tag( - url=f"{script_host}/recaptcha/enterprise.js?render={website_key}" - ) - await page.wait_for_function(wait_expression, timeout=timeout_ms) + script_url = f"{script_host}/{script_path}?render={website_key}" + if not await self._inject_recaptcha_script(page, script_url, marker): + continue + if await self._poll_enterprise_ready(page, timeout_ms): return True - except Exception as tag_error: - debug_logger.log_warning( - f"[BrowserCaptcha] Token-{self.token_id} {label}add_script_tag({script_host}) 失败: " - f"{type(tag_error).__name__}: {str(tag_error)[:150]}" - ) + debug_logger.log_warning( + f"[BrowserCaptcha] Token-{self.token_id} {label}注入后仍未就绪: {script_url}" + ) return False @staticmethod diff --git a/src/services/recaptcha_runtime.py b/src/services/recaptcha_runtime.py index 1bf9cf3f..e8409c63 100644 --- a/src/services/recaptcha_runtime.py +++ b/src/services/recaptcha_runtime.py @@ -59,118 +59,133 @@ })(); """ +# Execution strategy (in order): +# 1. the pre-hijack `enterprise.execute` closure captured by the bind hook +# (older Flow builds), +# 2. `grecaptcha.enterprise.execute.call(enterprise, siteKey, {action})` +# (newer Flow builds no longer call `.bind()`, so nothing gets captured; +# this is the documented enterprise-v3 usage and works without a render), +# 3. render into a *fresh empty* container and execute the widget id. The old +# code rendered into `document.documentElement`, which always contains +# children and makes reCAPTCHA throw +# "reCAPTCHA placeholder element must be empty". RECAPTCHA_EXECUTE_SOURCE = r"""(async (siteKey, requestedAction, timeoutMs) => { const storageKey = '__flow2apiTrustedRecaptchaExecute'; - for (let attempt = 0; attempt < 40; attempt += 1) { + + for (let attempt = 0; attempt < 20; attempt += 1) { if (window[storageKey]) break; await new Promise(resolve => setTimeout(resolve, 250)); } + const enterprise = window.grecaptcha && window.grecaptcha.enterprise; const capturedExecute = !!window[storageKey]; - let execute = window[storageKey]; - let executeSource = ''; - try { - executeSource = String(execute || ''); - } catch (error) {} + const debug = { + hookInstalled: !!window.__flow2apiTrustedRecaptchaHookInstalled, + actionGuard: !!window.__flow2apiRecaptchaActionGuardInstalled, + captured: capturedExecute, + executeType: enterprise ? typeof enterprise.execute : 'missing', + renderType: enterprise ? typeof enterprise.render : 'missing', + url: location.href + }; + const errors = []; - if (!execute) { - const enterprise = window.grecaptcha && window.grecaptcha.enterprise; - execute = enterprise && enterprise.execute; + const runExecute = async (label, factory) => { try { - executeSource = String(execute || ''); - } catch (error) {} - if ( - executeSource.includes('extension_hijack_detected') && - !window.__flow2apiRecaptchaActionGuardInstalled - ) { - return { - ok: false, - error: 'trusted reCAPTCHA action guard is unavailable', - debug: { - hookInstalled: !!window.__flow2apiTrustedRecaptchaHookInstalled, - actionGuard: !!window.__flow2apiRecaptchaActionGuardInstalled, - captured: !!window.__flow2apiTrustedRecaptchaExecute, - url: location.href + const token = await new Promise((resolve, reject) => { + let settled = false; + const finish = (handler, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + handler(value); + }; + const timer = setTimeout(() => { + finish(reject, new Error('execute timeout')); + }, timeoutMs); + try { + Promise.resolve(factory()).then( + value => finish(resolve, value), + error => finish(reject, error) + ); + } catch (error) { + finish(reject, error); } - }; + }); + const normalized = typeof token === 'string' ? token.trim() : ''; + if (normalized) return normalized; + errors.push(label + ': empty token'); + } catch (error) { + errors.push( + label + ': ' + + (error && error.message ? error.message : String(error)) + ); } - } + return null; + }; - if (typeof execute !== 'function') { - return { ok: false, error: 'reCAPTCHA execute is unavailable' }; + if (capturedExecute && typeof window[storageKey] === 'function') { + const token = await runExecute('captured', () => { + return window[storageKey](siteKey, { action: requestedAction }); + }); + if (token) return { ok: true, token, debug }; } - let executeTarget = siteKey; - try { + if (enterprise && typeof enterprise.execute === 'function') { + let executeSource = ''; + try { + executeSource = String(enterprise.execute || ''); + } catch (error) {} if ( - !capturedExecute && - window.grecaptcha && - window.grecaptcha.enterprise && - typeof window.grecaptcha.enterprise.render === 'function' && - window.__flow2apiRecaptchaClientId === undefined + executeSource.includes('extension_hijack_detected') && + !window.__flow2apiRecaptchaActionGuardInstalled ) { - window.__flow2apiRecaptchaClientId = - window.grecaptcha.enterprise.render( - document.documentElement, - { sitekey: siteKey, action: requestedAction } + errors.push('direct: trusted reCAPTCHA action guard is unavailable'); + } else { + const token = await runExecute('direct', () => { + return enterprise.execute.call( + enterprise, + siteKey, + { action: requestedAction } ); + }); + if (token) return { ok: true, token, debug }; } - if (window.__flow2apiRecaptchaClientId !== undefined) { - executeTarget = window.__flow2apiRecaptchaClientId; - } - } catch (renderError) { - return { - ok: false, - error: renderError && renderError.message - ? renderError.message - : String(renderError) - }; - } - try { - const token = await new Promise((resolve, reject) => { - let settled = false; - const finish = (handler, value) => { - if (settled) return; - settled = true; - handler(value); - }; - const timer = setTimeout(() => { - finish(reject, new Error('execute timeout')); - }, timeoutMs); + if (typeof enterprise.render === 'function') { try { - Promise.resolve(execute(executeTarget, { action: requestedAction })) - .then(value => { - clearTimeout(timer); - finish(resolve, value); - }) - .catch(error => { - clearTimeout(timer); - finish(reject, error); - }); - } catch (error) { - clearTimeout(timer); - finish(reject, error); + const container = document.createElement('div'); + container.setAttribute('data-flow2api-recaptcha', '1'); + container.style.display = 'none'; + (document.body || document.documentElement) + .appendChild(container); + const clientId = enterprise.render( + container, + { sitekey: siteKey, action: requestedAction } + ); + const token = await runExecute('render', () => { + return enterprise.execute.call( + enterprise, + clientId, + { action: requestedAction } + ); + }); + if (token) return { ok: true, token, debug }; + } catch (renderError) { + errors.push( + 'render: ' + + (renderError && renderError.message + ? renderError.message + : String(renderError)) + ); } - }); - return { ok: true, token }; - } catch (error) { - const debug = { - captured: capturedExecute, - actionGuard: !!window.__flow2apiRecaptchaActionGuardInstalled, - renderType: window.grecaptcha && window.grecaptcha.enterprise - ? typeof window.grecaptcha.enterprise.render - : 'missing', - clientId: String(window.__flow2apiRecaptchaClientId), - executeTarget: String(executeTarget), - url: location.href - }; - return { - ok: false, - error: (error && error.message ? error.message : String(error)) - + ' | ' + JSON.stringify(debug) - }; + } } + + return { + ok: false, + error: errors.join(' | ') || 'reCAPTCHA execute is unavailable', + debug + }; })(__SITE_KEY__, __REQUESTED_ACTION__, __TIMEOUT_MS__)""" diff --git a/tests/test_recaptcha_runtime.py b/tests/test_recaptcha_runtime.py index a203c766..40e70a92 100644 --- a/tests/test_recaptcha_runtime.py +++ b/tests/test_recaptcha_runtime.py @@ -115,5 +115,57 @@ def test_shared_execute_script_preserves_requested_action(self): self.assertEqual(result.returncode, 0, result.stderr) + @unittest.skipUnless( + shutil.which("node"), "Node.js is required for the JavaScript contract" + ) + def test_shared_execute_script_falls_back_to_direct_execute(self): + """新版 Flow 不再调用 execute.bind(),hook 捕获不到闭包时必须直接执行。""" + source = r""" +const vm = require('vm'); +const input = JSON.parse(require('fs').readFileSync(0, 'utf8')); +(async () => { + const sandbox = { setTimeout, clearTimeout, location: { href: 'https://flow.google.com/project/example' } }; + sandbox.window = sandbox; + vm.createContext(sandbox); + vm.runInContext(input.hook, sandbox); + vm.runInContext(` + globalThis.calls = []; + globalThis.grecaptcha = { enterprise: { + execute: function(key, options) { + calls.push({ thisOK: this === grecaptcha.enterprise, key, action: options.action }); + return Promise.resolve('captcha'); + } + }}; + `, sandbox); + if (sandbox.__flow2apiTrustedRecaptchaExecute) throw Error('closure should not be captured'); + const result = await vm.runInContext(input.script, sandbox); + if (!result.ok || result.token !== 'captcha') throw Error('captcha execution failed: ' + JSON.stringify(result)); + if (result.debug.captured !== false) throw Error('captured flag should be false'); + if (sandbox.calls.length !== 1) throw Error('expected one execute call, got ' + sandbox.calls.length); + const call = sandbox.calls[0]; + if (!call.thisOK) throw Error('execute must be invoked with enterprise as receiver'); + if (call.key !== input.siteKey) throw Error('site key mismatch'); + if (call.action !== input.action) throw Error('action mismatch'); +})().catch(error => { process.stderr.write(error.message); process.exitCode = 1; }); +""" + site_key = "site-key" + action = "IMAGE_GENERATION" + result = subprocess.run( + [shutil.which("node"), "-e", source], + input=json.dumps( + { + "hook": TRUSTED_RECAPTCHA_HOOK_SOURCE, + "script": build_recaptcha_execute_script(site_key, action, 1000), + "siteKey": site_key, + "action": action, + } + ), + text=True, + capture_output=True, + timeout=15, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + if __name__ == "__main__": unittest.main()