From 37c055b834e157965cd11f02753f7c587f54ef99 Mon Sep 17 00:00:00 2001 From: WASD1999xl <2960525002@qq.com> Date: Sun, 6 Sep 2026 15:49:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(captcha):=20guest=20=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E5=99=A8=E5=BD=92=E5=B1=9E=E6=94=B9=E4=B8=BA=E8=AF=8D=E6=B3=95?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=EF=BC=8C=E5=8F=96=E4=BB=A3=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=A0=88=E5=97=85=E6=8E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeDualTimers/makeDualConsole 靠 `/alicdn|aliyuncs/.test(new Error().stack)` 来决定一个定时器该进窗口注册表还是宿主车道。但调用栈描述的是"谁在调用",而我们 要问的是"这归谁所有",两者并不等价——宿主与 guest 的栈天然交织。因此它在两个 方向上都会误判,且都已在当前 master 上实测复现: 假阴性:guest 经 new Function 生成的代码,栈上不带 CDN 帧。它 arm 的定时器 落到宿主车道,happyDOM.close() 收不走。FeiLin 的心跳恰好会自我重新武装 (feilin008.js:170382 `tV=setInterval(tE,2e3)`),于是变成一个不朽定时器, 最终踩到已拆解的全局: ReferenceError: moveBy is not defined at tE (…/feilin008….js:1:169658) 假阳性:宿主代码在 guest 栈下被调用时被判成 guest,拿到窗口句柄。delay-0 路径下该句柄没有 unref,而 Bun 的 node:_http_server / undici / AbortSignal.timeout 都会 unref——这正是 v4.5.2 的崩溃形态: TypeError: setTimeout(() => {}, 0).unref is not a function 归属是"代码从哪来"的属性,所以改为词法绑定:guest 脚本在 evaluateScript 处被 包进 `with (scope) { … }`,scope 持有该窗口的定时器、console 和一个 Function 替身。标识符解析在语法分析期就定死,运行时不再查栈,两个方向的误判都不再可 表达。globalThis 上的定时器因此始终保持宿主原生实现,unref 契约恒成立。 三处设计取舍,都是被实测推翻后才定下的: • 用 `with` 而非 IIFE 包装。函数包装会吞掉 guest 的顶层 var/function 声明, initAliyunCaptcha 永远不会出现,每次求解都卡在等待它超时(实测 ok=0 fail=3)。`with` 不引入变量环境,声明的全局语义得以保留。 • 需要 Function 替身。`new Function(body)` 在全局作用域编译,生成的函数又能 看到宿主定时器,逃逸口重新打开(实测其心跳在窗口关闭后仍触发 7 次)。替身 把生成的函数体重新包进同一个 scope,并向下传递自身以覆盖嵌套生成。`eval` 无需处理,它本就继承调用处的作用域链。 • console 一并纳入 scope。FeiLin 每秒左右用不可见的 `%c%d` 格式串探测 devtools;解析到宿主 console 会把 TUI 的交替屏刷成一片裸 "NaN"。 测试相应从"钉住实现"改为"验证可观察契约":不再断言双分派器的内部结构,而是对 5 条 guest 入口路径(直接调用 / new Function / Function 无 new / 两层嵌套 生成 / eval 返回)分别验证"心跳随窗口死亡",外加顶层声明可见性与宿主 unref 契约。 验证(5 并发 × 6 波真实求解 + 133 万次并发 HTTP + 空闲越过墓碑期): 关窗后仍反复触发的定时器 0 个(改动前存在);致命未捕获异常 0;宿主 unref 契约全程保持;HTTP 1,332,201 成功 / 0 失败;求解成功率 80%,同参数 stock 基线 65%。 --- src/proxy/captcha-happy.test.ts | 361 ++++++++++++++++--------------- src/proxy/captcha-happy.ts | 368 +++++++++++++++++--------------- 2 files changed, 380 insertions(+), 349 deletions(-) diff --git a/src/proxy/captcha-happy.test.ts b/src/proxy/captcha-happy.test.ts index 2ce5b09..9b9d0a0 100644 --- a/src/proxy/captcha-happy.test.ts +++ b/src/proxy/captcha-happy.test.ts @@ -1,112 +1,140 @@ import { describe, expect, test } from "bun:test"; +import { GlobalWindow } from "happy-dom"; import { + createDom, + destroyDom, installGlobalWindowAlias, - makeDualConsole, - makeDualTimers, removeGlobalWindowAlias, } from "./captcha-happy.js"; -describe("dual console (guest spam guard)", () => { - test("drops every console method's call while the guest predicate holds", () => { - const seen: string[] = []; - const recorder: Record = {}; - for (const m of ["log", "debug", "info", "dir", "table", "group", "warn", "error"]) { - recorder[m] = (...a: unknown[]) => { seen.push(m + ": " + a.join(" ")); }; +// The guest-timer contract, stated behaviourally: a timer armed by guest code +// MUST stop firing once its window is destroyed, and the host's own timers +// MUST keep the Node ref/unref contract throughout. Both were violated by the +// stack-sniffing dispatcher this replaced — in opposite directions. +describe("guest timer ownership (lexical scope)", () => { + const EVALUATE_SCRIPT = "Symbol(evaluateScript)"; + const GUEST_URL = "https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin008.js"; + + function evaluateScriptSymbol(w: object): symbol | undefined { + for (const target of [w, Object.getPrototypeOf(w)]) { + const found = Object.getOwnPropertySymbols(target ?? {}).find( + (s) => String(s) === EVALUATE_SCRIPT, + ); + if (found) return found; } - const orig = console as unknown as Record; - const saved = Object.fromEntries( - Object.keys(recorder).map((k) => [k, orig[k]]), - ); - Object.assign(orig, recorder); + return undefined; + } + /** + * Arm a repeating timer from guest source, destroy the window, and report + * how many times the callback fired before and after. `after > 0` means the + * timer escaped onto the host lane and is now immortal. + * + * Real timers are the subject under test, not incidental latency: the whole + * question is whether happy-dom's registry cancels these handles at + * `close()`. Faking the clock would replace the very mechanism being + * verified. Waits are condition-driven (first tick / quiet period) rather + * than fixed guesses. + */ + async function ticksAcrossTeardown(guestSource: string): Promise<{ before: number; after: number }> { + const dom = await createDom("sgp", "no8xfe"); + const w = dom.window as unknown as Record; + let ticks = 0; + (globalThis as Record).__capTestTick = () => { ticks++; }; try { - // Build the dual console against the recorder and route calls through - // it with the guest predicate forced on — the FeiLin spam condition. - const dual = makeDualConsole(() => true) as Record void>; - dual.log!("%c%d", "font-size:0;color:transparent", "Error"); - dual.debug!("anything"); - dual.dir!({ an: "object" }); - dual.table!([1, 2]); - expect(seen).toEqual([]); - - // Host condition — identical calls must forward untouched. - const forward = makeDualConsole(() => false) as Record void>; - forward.log!("hello", 42); - forward.error!("[real] failure"); - expect(seen).toEqual(["log: hello 42", "error: [real] failure"]); + const sym = evaluateScriptSymbol(w); + if (!sym) throw new Error("happy-dom build exposes no evaluateScript symbol"); + (w[sym] as (code: string, options: unknown) => unknown)(guestSource, { filename: GUEST_URL }); + // Wait for the heartbeat to prove it is live, not for a fixed duration. + const deadline = Date.now() + 5_000; + while (ticks === 0 && Date.now() < deadline) await Bun.sleep(10); + const before = ticks; + destroyDom(w); + // A cancelled interval produces no further ticks; give it several + // periods of the 40ms heartbeat to prove silence. + await Bun.sleep(300); + return { before, after: ticks - before }; } finally { - Object.assign(orig, saved); + try { clearInterval((globalThis as Record).__capTestArmed as never); } catch {} + delete (globalThis as Record).__capTestTick; + delete (globalThis as Record).__capTestArmed; } - }); -}); - -describe("dual timers (host unref contract vs guest window registry)", () => { - type Fn = (...args: unknown[]) => unknown; - - function makeFixture() { - const hostCalls: string[] = []; - const winCalls: string[] = []; - const hostTimer = { unref() {}, ref() {} }; - const host: Record = { - setTimeout: (...a: unknown[]) => { hostCalls.push("setTimeout:" + a[1]); return hostTimer; }, - setInterval: (...a: unknown[]) => { hostCalls.push("setInterval:" + a[1]); return hostTimer; }, - clearTimeout: (...a: unknown[]) => { hostCalls.push("clearTimeout"); }, - clearInterval: (...a: unknown[]) => { hostCalls.push("clearInterval"); }, - }; - const win: Record = { - setTimeout: (...a: unknown[]) => { winCalls.push("setTimeout:" + a[1]); return 0; }, - setInterval: (...a: unknown[]) => { winCalls.push("setInterval:" + a[1]); return 0; }, - clearTimeout: (...a: unknown[]) => { winCalls.push("clearTimeout"); }, - clearInterval: (...a: unknown[]) => { winCalls.push("clearInterval"); }, - }; - return { host, win, hostCalls, winCalls, hostTimer }; } - test("host-stack calls keep the host timer objects (the .unref() contract)", () => { - const { host, win, hostCalls, winCalls, hostTimer } = makeFixture(); - const dual = makeDualTimers(win, host, () => false); - const t = dual.setTimeout(() => {}, 500) as { unref(): void }; - expect(hostCalls).toEqual(["setTimeout:500"]); - expect(winCalls).toEqual([]); - expect(t).toBe(hostTimer); - expect(typeof t.unref).toBe("function"); - dual.clearTimeout(t); - expect(hostCalls).toEqual(["setTimeout:500", "clearTimeout"]); - }); + const ARM = "globalThis.__capTestArmed ="; + const TICK = "globalThis.__capTestTick"; - test("guest-stack calls land on the window registry, reading it live", () => { - const { host, win, hostCalls, winCalls } = makeFixture(); - const dual = makeDualTimers(win, host, () => true); - expect(dual.setTimeout(() => {}, 16)).toBe(0); - // Guest hooks of window.setTimeout apply to guest callers (pe-VM - // scheduler behavior), including hooks installed AFTER the dual was - // built — the window function is resolved per call. - win.setTimeout = () => 42; - expect(dual.setTimeout(() => {}, 1)).toBe(42); - expect(hostCalls).toEqual([]); - expect(winCalls).toEqual(["setTimeout:16"]); - }); + // Each row is a distinct way guest code reaches `setInterval`. The + // `new Function` rows are the ones a stack-based router cannot see: the + // generated frame carries no CDN source URL, so its heartbeat used to be + // armed on the immortal host lane and outlived the window — the field + // crash "ReferenceError: moveBy is not defined" from feilin008.js's `tE`. + const GUEST_ENTRY_POINTS: Record = { + "direct call": `${ARM} setInterval(${TICK}, 40);`, + "generated by new Function": `${ARM} (new Function("t","return setInterval(t,40);"))(${TICK});`, + "generated without new": `${ARM} Function("t","return setInterval(t,40);")(${TICK});`, + "generated two levels deep": `${ARM} (new Function("t","return (new Function('u','return setInterval(u,40);'))(t);"))(${TICK});`, + "returned from eval": `${ARM} eval("(function(t){ return setInterval(t,40); })")(${TICK});`, + }; - test("the crash shape: a window timer without unref never reaches host callers", () => { - const { host, win } = makeFixture(); - // Simulate the v4.5.2 field crash preconditions: the pe-VM hooks the - // window's setTimeout into a wrapper that loses the handle, and Bun's - // node:_http_server finishes a response and arms its keep-alive timer - // through the aliased global setTimeout, then calls .unref() on the - // result. - win.setTimeout = (() => 0) as unknown; - const dual = makeDualTimers(win, host, () => false); - const timer = dual.setTimeout(() => {}, 5000) as { unref?: () => void }; - expect(typeof timer?.unref).toBe("function"); - expect(() => timer.unref!()).not.toThrow(); - }); + for (const [label, source] of Object.entries(GUEST_ENTRY_POINTS)) { + test(`a heartbeat armed via ${label} dies with the window`, async () => { + const { before, after } = await ticksAcrossTeardown(source); + expect(before).toBeGreaterThan(0); // the timer really was running + expect(after).toBe(0); + }, 20_000); + } - test("dispatchers look native to guest toString sweeps", () => { - const { host, win } = makeFixture(); - const dual = makeDualTimers(win, host, () => false); - expect(dual.setTimeout.name).toBe("setTimeout"); - expect(String(dual.setTimeout)).toBe("function setTimeout() { [native code] }"); - expect(String(dual.clearInterval)).toBe("function clearInterval() { [native code] }"); + test("guest top-level declarations still reach the global object", async () => { + // The scope is applied with `with`, not an IIFE, precisely so that guest + // `var`/`function` declarations keep escaping. Wrapping in a function + // swallowed them and `initAliyunCaptcha` never appeared — every solve + // then timed out waiting for it. + const dom = await createDom("sgp", "no8xfe"); + const w = dom.window as unknown as Record; + try { + const sym = evaluateScriptSymbol(w)!; + (w[sym] as (code: string, options: unknown) => unknown)( + "function __capTestTopLevel(){ return 'visible'; } var __capTestVar = 7;", + { filename: GUEST_URL }, + ); + const g = globalThis as Record; + expect(typeof g.__capTestTopLevel).toBe("function"); + expect(g.__capTestVar).toBe(7); + } finally { + destroyDom(w); + delete (globalThis as Record).__capTestTopLevel; + delete (globalThis as Record).__capTestVar; + } + }, 20_000); +}); + +describe("host timer contract during a solve", () => { + test("globalThis timers stay pristine while a window is aliased", async () => { + // Bun internals (node:_http_server keep-alive, undici, AbortSignal.timeout) + // arm timers through the bare global and call `.unref()` on the result. + // The previous dispatcher handed them a window timer whenever a guest frame + // happened to sit on the stack, producing + // "TypeError: setTimeout(...).unref is not a function" — a proxy-wide crash. + const pristine = globalThis.setTimeout; + const w = new GlobalWindow({ + url: "https://zcode.z.ai/", + settings: { suppressInsecureJavaScriptEnvironmentWarning: true }, + }); + installGlobalWindowAlias(globalThis, w, 20); + try { + expect(globalThis.setTimeout).toBe(pristine); + const handle = setTimeout(() => {}, 0); + expect(typeof handle.unref).toBe("function"); + handle.unref(); + clearTimeout(handle); + const signal = AbortSignal.timeout(50); + expect(signal.aborted).toBe(false); + } finally { + await w.happyDOM.close(); + removeGlobalWindowAlias(globalThis, w); + await Bun.sleep(60); + } }); }); @@ -114,16 +142,12 @@ describe("alias lifecycle (install/remove descriptor contract)", () => { const TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]; function makeSandbox() { - // Sentinel the pristine host fns return — the dual's host lane must - // surface it untouched even when the window's timers are hostile. - const pristine = { marker: "pristine-host-timer" }; const g: Record = {}; - for (const p of TIMER_PROPS) { - g[p] = () => pristine; - } - // Host-existing globals beyond the timers — atob/btoa are what - // client-signing.ts uses (JWT base64); the window also exposes them, so - // the generic alias loop overwrites the host versions. + const pristine = { marker: "pristine-host-timer" }; + for (const p of TIMER_PROPS) g[p] = () => pristine; + // Host-existing globals the window also exposes: the generic alias loop + // overwrites these, so teardown must put the host versions back. + // client-signing.ts:100/:115 calls bare `atob`/`btoa` between solves. const hostAtob = (s: string) => `host-atob(${s})`; g.atob = hostAtob; g.btoa = (s: string) => `host-btoa(${s})`; @@ -131,13 +155,7 @@ describe("alias lifecycle (install/remove descriptor contract)", () => { // tombstone must never grace-delete it after the restore, even though a // restored getter passes the `d?.get` re-check. const hostNavigator = { userAgent: "host" }; - Object.defineProperty(g, "navigator", { - get: () => hostNavigator, - configurable: true, - }); - // Window stub whose timers are hooked/lossy (pe-VM scheduler shape) and - // which carries the DOM interface guest stragglers probe (Text — the - // v4.5.2 "Text is not defined" field report from feilin005.js). + Object.defineProperty(g, "navigator", { get: () => hostNavigator, configurable: true }); const w: Record = { setTimeout: () => 0, setInterval: () => 0, @@ -151,75 +169,63 @@ describe("alias lifecycle (install/remove descriptor contract)", () => { return { g, w, pristine, hostNavigator }; } - test("host lane serves the PRISTINE timers, not the generic-alias getters (capture-order regression)", () => { + test("the alias never touches the host timers", () => { const { g, w, pristine } = makeSandbox(); + const before = Object.fromEntries( + TIMER_PROPS.map((p) => [p, Object.getOwnPropertyDescriptor(g, p)]), + ); installGlobalWindowAlias(g, w); - // A host-stack call (this test's stack has no guest CDN frames) through - // the aliased global must return the pristine fn's return value. Under - // the capture-order bug the host lane fell back to g[prop], which the - // generic props loop had already turned into a window-forwarding - // accessor — this would read 0 from the hooked window stub instead. - const hostLane = g.setTimeout as (...a: unknown[]) => unknown; - expect(hostLane(() => {}, 1)).toBe(pristine); - removeGlobalWindowAlias(g, w); + try { + // Bun's runtime resolves these by bare identifier and unrefs the result. + // Aliasing them onto the window is what produced + // "setTimeout(...).unref is not a function" in the field. + for (const p of TIMER_PROPS) { + expect(Object.getOwnPropertyDescriptor(g, p)).toEqual(before[p]); + } + expect((g.setTimeout as () => unknown)()).toBe(pristine); + } finally { + removeGlobalWindowAlias(g, w); + } }); - test("remove restores the pre-install descriptors byte-identically", () => { + test("host-existing globals are served from the window during a solve and restored after", () => { const { g, w } = makeSandbox(); - const before = Object.fromEntries( - TIMER_PROPS.map((p) => [p, Object.getOwnPropertyDescriptor(g, p)]), - ); installGlobalWindowAlias(g, w); - expect(Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBeDefined(); + expect((g.atob as (s: string) => string)("x")).toBe("win-atob(x)"); removeGlobalWindowAlias(g, w); - for (const p of TIMER_PROPS) { - expect(Object.getOwnPropertyDescriptor(g, p)).toEqual(before[p]); - expect(g[p]).toBe(before[p]!.value); - } + // A hard delete here left client-signing's bare `btoa`/`atob` as + // ReferenceErrors — the field-reported failure this restore prevents. + expect(Object.getOwnPropertyDescriptor(g, "atob")?.get).toBeUndefined(); + expect((g.atob as (s: string) => string)("y")).toBe("host-atob(y)"); + expect((g.btoa as (s: string) => string)("y")).toBe("host-btoa(y)"); }); test("concurrent installs restore exactly once, on the last remove", () => { const { g } = makeSandbox(); - const w1: Record = { setTimeout: () => 1, setInterval: () => 1, clearTimeout: () => {}, clearInterval: () => {} }; - const w2: Record = { setTimeout: () => 2, setInterval: () => 2, clearTimeout: () => {}, clearInterval: () => {} }; - const orig = g.setTimeout; + const w1: Record = { atob: (s: string) => `w1(${s})` }; + const w2: Record = { atob: (s: string) => `w2(${s})` }; + const orig = g.atob; installGlobalWindowAlias(g, w1); installGlobalWindowAlias(g, w2); removeGlobalWindowAlias(g, w1); // refcount 2→1: no restore yet - expect(typeof Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBe("function"); - expect(g.setTimeout).not.toBe(orig); + expect(g.atob).not.toBe(orig); removeGlobalWindowAlias(g, w2); // refcount 1→0: restore - expect(g.setTimeout).toBe(orig); - }); - - test("host-existing globals beyond the timers (atob/btoa) survive a solve wave — client-signing contract", () => { - const { g, w } = makeSandbox(); - installGlobalWindowAlias(g, w); - // During the solve the alias serves the window's versions (guest gets - // the happy-dom base64, writes to them stay on the window). - const inSolve = (g.atob as (s: string) => string)("x"); - expect(inSolve).toBe("win-atob(x)"); - removeGlobalWindowAlias(g, w); - // After teardown the PRISTINE host functions are back — the - // field-reported failure mode was a hard delete leaving client-signing's - // bare `btoa`/`atob` (client-signing.ts:100/:115) as ReferenceErrors. - expect(Object.getOwnPropertyDescriptor(g, "atob")?.get).toBeUndefined(); - expect((g.atob as (s: string) => string)("y")).toBe("host-atob(y)"); - expect((g.btoa as (s: string) => string)("y")).toBe("host-btoa(y)"); + expect(g.atob).toBe(orig); }); - test("window-sourced globals stay resolvable through the post-teardown grace period (Text straggler fix)", async () => { + test("window-sourced globals stay resolvable through the post-teardown grace period", async () => { const { g, w, hostNavigator } = makeSandbox(); installGlobalWindowAlias(g, w, 15); removeGlobalWindowAlias(g, w); - // Timers/console are restored immediately; Text (window-sourced) is - // tombstoned — still resolvable, still the (closed) window's value. - expect(Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBeUndefined(); + // Host globals are back at once; window-sourced ones (Text) linger so a + // straggling guest continuation resolves them instead of throwing + // "Text is not defined" (v4.5.2 field report from feilin005.js). + expect(Object.getOwnPropertyDescriptor(g, "atob")?.get).toBeUndefined(); expect(Object.getOwnPropertyDescriptor(g, "Text")?.get).toBeDefined(); expect(g.Text).toBe(w.Text); - await new Promise((r) => setTimeout(r, 70)); - // Grace expired: the real deletion ran — but the restored HOST getter - // (navigator) survived it. + // Real elapsed time is the mechanism under test: the tombstone is a + // wall-clock grace period scheduled on the pristine host setTimeout. + await Bun.sleep(70); expect(g.Text).toBeUndefined(); expect(Object.getOwnPropertyDescriptor(g, "Text")?.get).toBeUndefined(); expect(g.navigator).toBe(hostNavigator); @@ -247,40 +253,49 @@ describe("alias lifecycle (install/remove descriptor contract)", () => { test("a new install cancels the pending tombstone", async () => { const { g, w } = makeSandbox(); const w2: Record = { - setTimeout: () => 2, setInterval: () => 2, clearTimeout: () => {}, clearInterval: () => {}, Text: class FakeText2 {}, atob: (s: string) => `win2-atob(${s})`, - btoa: (s: string) => `win2-btoa(${s})`, navigator: { userAgent: "window2" }, }; installGlobalWindowAlias(g, w, 15); removeGlobalWindowAlias(g, w); - installGlobalWindowAlias(g, w2, 15); // new wave before the grace expires - await new Promise((r) => setTimeout(r, 70)); - // Still aliased to the second window — the first tombstone aborted. - expect(Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBeDefined(); - expect(g.Text).toBe(w2.Text); + installGlobalWindowAlias(g, w2, 15); // new wave inside the grace period + await Bun.sleep(70); + expect(g.Text).toBe(w2.Text); // first tombstone aborted removeGlobalWindowAlias(g, w2); - // The SECOND wave's tombstone must own the cleanup: the overlapping-wave - // sequence is exactly where the stale-accessor laundering pinned the - // first closed window on the globals forever (review finding 2026-08-31). - await new Promise((r) => setTimeout(r, 70)); + // The SECOND wave's tombstone owns the cleanup: this overlap is where + // stale-accessor laundering used to pin the first closed window forever. + await Bun.sleep(70); expect(g.Text).toBeUndefined(); expect(g.window).toBeUndefined(); - expect(Object.getOwnPropertyDescriptor(g, "setTimeout")?.get).toBeUndefined(); }); -}); -describe("browser globals the guest SDK dereferences bare", () => { + test("an expired tombstone leaves callable stubs, never a ReferenceError", async () => { + // The grace period ends, but guest stragglers can still be running: the + // FeiLin heartbeat `tE` re-arms itself every 2s and dereferences `moveBy` + // (feilin008.js:169658). A hard `delete` turned that read into an uncaught + // ReferenceError — fatal in the host realm, and the field crash this + // guards. The name must stay resolvable and callable. + const { g, w } = makeSandbox(); + (w as Record).moveBy = () => {}; + installGlobalWindowAlias(g, w, 15); + removeGlobalWindowAlias(g, w); + await Bun.sleep(70); + expect(typeof g.moveBy).toBe("function"); + expect(() => (g.moveBy as () => void)()).not.toThrow(); + // The stub must not keep the closed window reachable from globalThis. + expect(g.moveBy).not.toBe((w as Record).moveBy); + }); + test("requestAnimationFrame/cancelAnimationFrame resolve during a solve", () => { // Bun has no native rAF. While these sat in HOST_CRITICAL_GLOBALS the // alias pass skipped them, so the FeiLin bundle's 9 bare // `requestAnimationFrame` references (only one `typeof`-guarded) were - // unresolvable — the same silent fingerprint degradation `print` caused, - // visible only as a lower solve rate. + // unresolvable — silent fingerprint degradation, visible only as a lower + // solve rate. const g: Record = {}; const w: Record = { - requestAnimationFrame: (cb: () => void) => 1, + requestAnimationFrame: () => 1, cancelAnimationFrame: () => {}, }; installGlobalWindowAlias(g, w, 15); diff --git a/src/proxy/captcha-happy.ts b/src/proxy/captcha-happy.ts index eeda5d0..7efe44a 100644 --- a/src/proxy/captcha-happy.ts +++ b/src/proxy/captcha-happy.ts @@ -515,6 +515,129 @@ function makeInterceptor(bypassPeCache = false) { }; } +// ── Lexical guest scope: timer OWNERSHIP, not caller guessing ────────────── +// Under Bun, guest scripts execute in the HOST realm, so a bare `setTimeout` +// inside SDK code resolves to the host's. Host timers outlive the window: +// a stray FeiLin callback that re-arms its 2s heartbeat (feilin008.js: +// `tV = setInterval(tE, 2e3)`) after destroyDom keeps firing forever and +// eventually dereferences a torn-down global — the field-reported +// "ReferenceError: moveBy is not defined" that killed the TUI. +// +// Guest timers must therefore land on the WINDOW registry, which happy-dom +// clears in happyDOM.close(). The previous approach decided this at CALL time +// by sniffing `new Error().stack` for a CDN frame, but a stack describes the +// call chain, not ownership, and it misjudges BOTH ways: +// • false negative — guest code built via `new Function` carries no CDN +// frame, so its heartbeat escaped onto the immortal host lane; +// • false positive — host runtime code invoked beneath a guest frame was +// handed a window timer with no `.unref()`, the v4.5.2 crash shape. +// Ownership is a property of where CODE COMES FROM, so we bind it lexically. +// Each guest script is evaluated inside `with (scope) { … }`, where `scope` +// carries this window's timer methods. Identifier resolution is settled by the +// scope chain at parse time; no stack is ever consulted, so neither misjudgement +// is expressible. `with` (not an IIFE wrapper) because guest top-level `var` and +// `function` declarations must keep escaping to the global object — an IIFE +// swallows them and `initAliyunCaptcha` never appears (measured: every solve +// timed out). The FeiLin/pe bundles have no top-level "use strict" (their +// `"use strict"` directives sit inside module functions, which is fine), so +// `with` parses; a script that did carry one would throw at parse time and take +// the unwrapped fallback path in installEvalInstrumentation. +const GUEST_TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]; + +// Guest scopes are keyed per window so concurrent solves never share timers: +// the wrapper reads `globalThis[GUEST_SCOPE_ROOT][id]` at RUN time, and each +// window gets its own id. A plain object (not a Map) because the wrapper text +// indexes it directly from guest source. +const GUEST_SCOPE_ROOT = "__capGuestScopes"; +let _guestScopeSeq = 0; + +/** + * Create this window's guest scope and return the id the wrapper embeds. + * + * Holds the window's own timer methods (bound to the window), a `Function` + * stand-in (see makeScopedFunction), and the window's console. The console + * matters as much as the timers: the FeiLin SDK probes for devtools by + * printing invisible `%c%d` format strings across every console method, ~1/sec + * while solving. Resolved lexically it lands on the window's silent console; + * left to the host it garbles the TUI's alternate screen with bare "NaN" rows. + */ +function installGuestScope(w) { + const root = (globalThis[GUEST_SCOPE_ROOT] ??= Object.create(null)); + const id = `w${++_guestScopeSeq}`; + const scope = Object.create(null); + for (const name of GUEST_TIMER_PROPS) { + const fn = w[name]; + scope[name] = typeof fn === "function" ? fn.bind(w) : fn; + } + scope.Function = makeScopedFunction(w); + if (!_DEBUG && w.console) scope.console = w.console; + root[id] = scope; + w.__capScopeId = id; + return id; +} + +/** Drop the scope when the window dies, so it cannot pin a closed window. */ +function removeGuestScope(w) { + try { + const id = w && w.__capScopeId; + const root = globalThis[GUEST_SCOPE_ROOT]; + if (id && root) delete root[id]; + } catch {} +} + +/** + * A `Function` stand-in for guest scope. `new Function(body)` compiles in the + * GLOBAL scope, so a generated function would see the host timers again and + * re-open the escape hatch (measured: its heartbeat outlived window close). + * This variant re-wraps the generated body in the same `with` scope, so code + * the pe bytecode VM generates at runtime inherits the window's timers too. + * `eval` needs no equivalent: it inherits the caller's scope chain already. + */ +function makeScopedFunction(w) { + const Scoped = function (...args) { + const body = args.length ? String(args[args.length - 1]) : ""; + const params = args.slice(0, -1).map(String).join(","); + const id = w.__capScopeId; + const source = + `return function(${params}){with(globalThis.${GUEST_SCOPE_ROOT}[${JSON.stringify(id)}]){\n${body}\n}}`; + return Function(source)(); + }; + // Guest fingerprint code sweeps name/toString over platform builtins. + Scoped.prototype = Function.prototype; + try { + Object.defineProperty(Scoped, "name", { value: "Function", configurable: true }); + Object.defineProperty(Scoped, "toString", { + value: () => "function Function() { [native code] }", + configurable: true, + writable: true, + }); + } catch {} + return Scoped; +} + +/** + * Wrap guest source so bare timer identifiers resolve to `w`'s registry. + * + * The completion value MUST survive: happy-dom's JavaScriptCompiler hands + * `evaluateScript` a `(function anonymous($happy_dom){…})` expression and calls + * the returned function. A bare `with (…) { … }` statement evaluates to + * undefined, so the compiled script was silently never executed — every solve + * then timed out waiting for `initAliyunCaptcha`. Wrapping in an arrow-bodied + * IIFE that RETURNS the inner expression keeps both properties: the value flows + * out, and guest code still sits lexically inside the `with` scope. + * + * Top-level `var`/`function` declarations keep their global-object semantics + * because `with` (unlike a function wrapper) introduces no variable + * environment of its own. + */ +function wrapGuestSource(code, filename, scopeId) { + const sourceUrl = filename && /^https?:/.test(String(filename)) ? `\n//# sourceURL=${filename}` : ""; + const scopeRef = `globalThis.${GUEST_SCOPE_ROOT}[${JSON.stringify(scopeId)}]`; + // The leading newline keeps guest line numbers aligned with the CDN + // original; the trailing one guards a source ending in a line comment. + return `with(${scopeRef}){\n${code}\n}${sourceUrl}`; +} + // ── Parse-fail instrumentation (host side) ───────────────────────────────── // Wraps happy-dom's VM eval funnel (window[PropertySymbol.evaluateScript]). // Every script tag / compiled module / dynamic chunk that happy-dom parses @@ -529,7 +652,25 @@ function installEvalInstrumentation(w) { } const orig = w[sym]; w[sym] = function (code, options) { + const scopeId = w.__capScopeId; try { + // Guest scripts run inside this window's `with` scope (wrapGuestSource): + // this funnel is the single entry point for every script tag, compiled + // module and dynamic pe/FeiLin chunk, so wrapping here covers them all. + // Our own GUEST_EVAL_PATCH goes through w.eval() and is unaffected. + if (scopeId) { + try { + return orig.call(this, wrapGuestSource(String(code ?? ""), options && options.filename, scopeId), options); + } catch (scopeErr) { + // Only a wrapper-induced parse failure (e.g. a top-level "use + // strict" making `with` illegal) falls back — a genuine error from + // the guest body must propagate to the diagnostics path below. + if (!(scopeErr instanceof SyntaxError)) throw scopeErr; + process.stderr.write( + `[instr] guest scope rejected (${scopeErr.message.slice(0, 80)}), evaluating unwrapped\n`, + ); + } + } return orig.call(this, code, options); } catch (err) { try { @@ -630,11 +771,11 @@ function installNativeToString(w) { try { const v = desc.get.call(obj); if (typeof v === "function") mask(v); - // Probing a getter can hand back an already-rejected promise (WHATWG - // stream `closed`/`ready` reject when the receiver is the prototype - // rather than an instance). Nobody awaits these, so without a sink - // each probe surfaced as an unhandledRejection on every solve — - // noise that buried real diagnostics. + // Probing a getter can hand back a promise that is already rejected + // (WHATWG stream `closed`/`ready` reject when the receiver is the + // prototype, not an instance). Nobody awaits these, so without a + // sink each probe surfaced as an unhandledRejection during every + // solve — noise that buried real diagnostics. else if (v && typeof v.then === "function") v.catch(() => {}); } catch {} } @@ -1715,6 +1856,8 @@ async function createDom(region, prefix) { // destroyed, so concurrent solves with window reuse stay consistent. applyPolyfills(w); installNativeToString(w); + // Guest timer scope must exist BEFORE the eval hook wraps any script. + installGuestScope(w); installEvalInstrumentation(w); // Bun alias pass runs AFTER polyfills so polyfilled props (Option, Video, // alert, ...) are visible to guest scripts via globalThis too. @@ -1769,7 +1912,6 @@ async function createDom(region, prefix) { return { window: w, browserFrame }; } -// Bun-only: alias the active window on globalThis (script tags run in the // Bun-only: alias the active window on globalThis (script tags run in the // host realm under Bun). Every own enumerable window property is exposed as a // getter so guest scripts resolving bare identifiers (window, document, @@ -1780,6 +1922,9 @@ async function createDom(region, prefix) { const HOST_CRITICAL_GLOBALS = new Set([ "process", "Bun", "console", "performance", "crypto", "fetch", "queueMicrotask", "structuredClone", "TextEncoder", "TextDecoder", + // Timers stay pristine on globalThis so Bun internals keep real Node timer + // objects (`.unref()`); guest code gets the window's registry lexically. + "setTimeout", "setInterval", "clearTimeout", "clearInterval", // NOTE: requestAnimationFrame/cancelAnimationFrame were removed from this // list (2026-09-06). Bun has no native rAF, so skipping the alias left a // bare `requestAnimationFrame` in the FeiLin bundle unresolvable (9 call @@ -1817,6 +1962,11 @@ const EXTRA_WINDOW_PROPS = [ "open", "close", "stop", "focus", "blur", "print", "alert", "confirm", "prompt", "getSelection", "find", ]; +// Subset of the above that a real browser implements as no-op-ish window +// methods. When the tombstone expires these become harmless stubs instead of +// being deleted, so a straggling guest callback that still calls `moveBy()` +// completes silently rather than raising a fatal ReferenceError. +const INERT_WINDOW_METHODS = new Set(EXTRA_WINDOW_PROPS); // Subset of the above that a real browser implements as no-op-ish window // methods. When the tombstone expires these become harmless stubs instead of @@ -1853,142 +2003,41 @@ const _hostSetTimeout = globalThis.setTimeout; // this set and always flow to the restore path. const _aliasGetters = new WeakSet(); -// Under Bun, guest scripts run in the HOST realm — a bare `console` inside -// SDK code resolves to the HOST console (that is the FeiLin spam channel: -// invisible devtools-format lines probed across every console method, ~1/sec -// while solving). While the aliases are installed, `console` on globalThis is -// replaced with a DUAL console: calls whose stack originates from guest SDK -// script URLs are dropped, everything else forwards to the console that was -// live at install time (the TUI's interception or the serve terminal — so -// host logging keeps working untouched). Restored on the last destroyDom. -// Skipped under CAPTCHA_DEBUG so guest logs stay inspectable. -let _savedConsoleDescriptor: PropertyDescriptor | undefined; -let _dualConsole: Record | null = null; - -// Same save/restore contract as the console descriptor, generalized to every -// HOST-EXISTING global the alias pass overwrites (see installGlobalWindowAlias -// for the full list rationale). Captured on the FIRST install of a wave -// (globals are pristine then), restored on the last remove. +// Same save/restore contract for every HOST-EXISTING global the alias pass +// overwrites (see installGlobalWindowAlias for the rationale). Captured on the +// FIRST install of a wave (globals are pristine then), restored on the last +// remove. let _savedHostGlobalDescriptors: Record | undefined; -const TIMER_PROPS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]; - -// Guest-stack detector shared by the dual console and dual timers: guest -// script frames carry their CDN source URL (o/g.alicdn.com, aliyuncs APIs). -function isGuestConsoleCall(): boolean { - const stack = new Error().stack ?? ""; - return /alicdn\.com|aliyuncs\.com/i.test(stack); -} - -/** Build a console facade that drops calls while `isGuest()` holds (test seam). */ -export function makeDualConsole(isGuest: () => boolean = isGuestConsoleCall): Record { - // Capture the console OBJECT at build time — methods must keep their - // original receiver (looking up `console` again inside the closure would - // resolve to the dual getter itself and recurse). - const host = console as unknown as Record; - const dual: Record = {}; - for (const key of Object.keys(host)) { - const fn = host[key]; - if (typeof fn !== "function") { - dual[key] = fn; - continue; - } - dual[key] = (...args: unknown[]) => { - if (isGuest()) return; - return (fn as (...a: unknown[]) => void).apply(host, args); - }; - } - return dual; -} - -function isGuestTimerCall(): boolean { - // Same evidence as the dual console — one shared predicate shape. - return isGuestConsoleCall(); -} - -/** - * Build dual setTimeout/setInterval/clearTimeout/clearInterval bound to one - * solve's window (test seam): calls whose stack originates from guest script - * URLs land on the window's timer registry (die with the window, so pe-VM - * callbacks can't outlive destroyDom), everything else — Bun internals like - * node:_http_server's keep-alive arming, our own modules — keeps the real - * host timer objects and their ref/unref contract. The window's function is - * read LIVE at call time so guest-side hooks of window.setTimeout still apply - * to guest callers; the host functions are captured once, immune to hooks. - */ -export function makeDualTimers( - win: Record, - host: Record unknown>, - isGuest: () => boolean = isGuestTimerCall, -): Record unknown> { - const dual: Record unknown> = {}; - for (const prop of TIMER_PROPS) { - const fn = function (...args: unknown[]) { - let guest = false; - try { - guest = isGuest(); - } catch (_) {} - if (guest) { - const wfn = win[prop]; - return typeof wfn === "function" ? wfn.apply(win, args) : undefined; - } - return host[prop]?.apply(undefined, args); - }; - // Look native to guest toString sweeps (same masking technique as - // installNativeToString): real browsers expose these as native code. - try { - Object.defineProperty(fn, "name", { value: prop, configurable: true }); - const nativeStr = `function ${prop}() { [native code] }`; - Object.defineProperty(fn, "toString", { - value: () => nativeStr, - configurable: true, - writable: true, - }); - } catch (_) {} - dual[prop] = fn; - } - return dual; -} +// Guest timer/console routing is LEXICAL (see the guest scope section above): +// bare `setTimeout`/`console` inside guest source resolve through the `with` +// scope to this window's own objects. No stack sniffing — the previous +// `/alicdn/.test(new Error().stack)` predicate answered "who is calling?" when +// the question is "who owns this?", and got both directions wrong (immortal +// guest heartbeats; host timers stripped of `.unref`). +// +// So globalThis keeps the PRISTINE host timers at all times, and Bun internals +// (node:_http_server keep-alive, undici, AbortSignal.timeout) always get real +// Node timer objects with an intact ref/unref contract. // Both take (g, w) explicitly so tests can drive the lifecycle against a -// sandbox global (same test-seam pattern as makeDualConsole/makeDualTimers). -// `tombstoneMs` (tests only) shortens the post-teardown grace period. +// sandbox global. `tombstoneMs` (tests only) shortens the grace period. export function installGlobalWindowAlias(g, w, tombstoneMs?) { - // Clamp a negative refcount (an unbalanced remove would otherwise land the - // NEXT install at 0 instead of 1: the snapshot is skipped and the dual - // timers' host lane falls back to just-installed window accessors — the - // v4.5.2 unref crash shape). + // Clamp a negative refcount: an unbalanced remove would otherwise land the + // NEXT install at 0 instead of 1 and skip the host-global snapshot, so + // teardown could never restore what it overwrote. if (_aliasRefCount < 0) _aliasRefCount = 0; _aliasRefCount += 1; _aliasGeneration += 1; // cancels any pending tombstone from a prior wave _tombstoneMs = typeof tombstoneMs === "number" ? tombstoneMs : ALIAS_TOMBSTONE_MS; - if (_aliasRefCount === 1 && !process.env.CAPTCHA_DEBUG) { - _dualConsole = makeDualConsole(); _savedConsoleDescriptor = Object.getOwnPropertyDescriptor(g, "console"); - try { - const consoleGetter = function () { - return _dualConsole; - }; - _aliasGetters.add(consoleGetter); - Object.defineProperty(g, "console", { - get: consoleGetter, - set(v) { - try { w.console = v; } catch (_) {} - }, - configurable: true, - }); - } catch (_) {} - } // Build the alias name set FIRST, then snapshot every HOST-EXISTING global // in it BEFORE anything is aliased. The generic props loop below overwrites // them with window-forwarding accessors (GlobalWindow own props outside - // HOST_CRITICAL_GLOBALS) — a capture taken after it would save those - // accessors, the dual-timer host lane would dispatch into the window, - // happyDOM.close() would cancel host-lane timers mid-solve, and the - // post-remove restore would reinstate accessors to a closed window (the - // v4.5.2 unref crash reborn). The snapshot covers far more than the four - // timers: atob/btoa (client-signing's base64 — field-reported - // ReferenceError), WebSocket, MessageEvent, CustomEvent, navigator, self, - // ... — every host global the window happens to expose (~25 today). + // HOST_CRITICAL_GLOBALS); a capture taken after it would save those + // accessors and the post-remove restore would reinstate accessors onto a + // closed window. The snapshot covers atob/btoa (client-signing's base64 — + // field-reported ReferenceError), WebSocket, MessageEvent, CustomEvent, + // navigator, self, ... — every host global the window happens to expose. const props = new Set(Object.getOwnPropertyNames(w)); for (const name of EXTRA_WINDOW_PROPS) props.add(name); // also walk the prototype chain one level (BrowserWindow getters like @@ -2046,40 +2095,11 @@ export function installGlobalWindowAlias(g, w, tombstoneMs?) { Object.defineProperty(g, prop, { get: getter, configurable: true }); } catch (_) {} } - // Guest timers must live on the window's timer registry (destroyed with - // the window). The host's setTimeout would let pe-VM callbacks fire after - // destroyDom, when the `window` alias is gone ("window is not defined"). - // BUT a bare getter onto w's timers hijacks the HOST's global timers for - // the whole solve: Bun ≥1.4 arms its node:_http_server keep-alive socket - // timer through the bare global setTimeout and calls .unref() on the - // result, and a guest-side hook of window.setTimeout (pe-VM scheduler) or - // the post-close window stub returns a value without .unref — an uncaught - // TypeError that kills the whole proxy (v4.5.2 field crash, Bun v1.4.0 - // binaries). So the four globals become DUAL dispatchers instead: calls - // whose stack originates from guest script URLs land on the window - // registry (same evidence base as the dual console), everything else - // keeps the real host timer objects and their ref/unref contract. - // (Descriptors were captured at the TOP of this function — see there.) - const hostTimers: Record unknown> = {}; - for (const prop of TIMER_PROPS) { - hostTimers[prop] = _savedHostGlobalDescriptors?.[prop]?.value ?? g[prop]; - } - const dualTimers = makeDualTimers(w, hostTimers); - for (const prop of TIMER_PROPS) { - try { - const getter = function () { return dualTimers[prop]; }; - _aliasGetters.add(getter); - Object.defineProperty(g, prop, { - get: getter, - // Writes through the alias (`setTimeout = x`, guest or host alike) - // land on the window like a real browser — they must NOT clobber - // the dispatcher itself; host callers always get the captured - // pristine functions regardless. - set(v) { try { w[prop] = v; } catch (_) {} }, - configurable: true, - }); - } catch (_) {} - } + // Timers are deliberately NOT aliased: globalThis keeps Bun's pristine + // functions so node:_http_server keep-alive, undici and AbortSignal.timeout + // always receive real Node timer objects with an intact `.unref()`. Guest + // code reaches the window's registry through the lexical `with` scope + // instead, so its callbacks still die in happyDOM.close(). // Dynamic catch-all: guest code occasionally references window methods that // only exist on the prototype (moveBy, scrollTo, ...) or lands mid-solve on // new props. Proxy fallback for any still-missing global property. @@ -2095,18 +2115,10 @@ export function installGlobalWindowAlias(g, w, tombstoneMs?) { export function removeGlobalWindowAlias(g, w) { _aliasRefCount -= 1; if (_aliasRefCount > 0) return; - // Host-contract globals come back IMMEDIATELY: the console (host logging - // between solves) and every HOST-EXISTING global the wave overwrote — the - // four timers (Bun internals call them by bare identifier and unref the - // result), atob/btoa (client-signing's JWT base64 — the field-reported - // ReferenceError), WebSocket/MessageEvent/navigator/self/... - if (_savedConsoleDescriptor) { - try { - Object.defineProperty(g, "console", _savedConsoleDescriptor); - } catch (_) {} - _savedConsoleDescriptor = undefined; - _dualConsole = null; - } + // Host-contract globals come back IMMEDIATELY: every HOST-EXISTING global + // the wave overwrote — atob/btoa (client-signing's JWT base64 — the + // field-reported ReferenceError), WebSocket/MessageEvent/navigator/self/... + // The timers and console were never aliased, so nothing to restore there. const restored = new Set(); if (_savedHostGlobalDescriptors) { for (const [name, desc] of Object.entries(_savedHostGlobalDescriptors)) { @@ -2176,6 +2188,10 @@ function destroyDom(win) { try { if (typeof Bun !== "undefined") removeGlobalWindowAlias(globalThis, win); } catch (_) {} + // The scope holds window-bound timer functions; dropping it releases the + // closed window and makes any straggler's `new Function` fall back to the + // host constructor (harmless: the window registry is already cleared). + removeGuestScope(win); try { shutdownSyncFetchWorker(); } catch (_) {} } From cbaf139b5ab88d4fcf7eb7ba0bcb8f6719041dbd Mon Sep 17 00:00:00 2001 From: WASD1999xl <2960525002@qq.com> Date: Sun, 6 Sep 2026 22:07:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(captcha):=20wrapGuestSource=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA=E6=8F=8F=E8=BF=B0=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审阅指出这段文档写的是一个被推翻的中间方案——"Wrapping in an arrow-bodied IIFE that RETURNS the inner expression"——而实现返回的是裸 `with(scope){…}` 语句,两者矛盾。 真实机制:`with` 是语句而非表达式,`eval` 返回它的块完成值,而块以最后一个 表达式语句完成,因此编译器交来的 `(function anonymous($happy_dom){…})` 原样流出,无需任何 IIFE 包装。(声明不产生完成值,所以以声明结尾的脚本同样 安全,沿用前一个表达式的值。) 注释同时保留选择 `with` 而非函数包装的另一半理由:`with` 引入的是对象环境 而非变量环境,guest 的顶层 var/function 声明得以继续外泄到全局对象。 顺带删除一处 rebase 到已合并的 #44 之上时产生的 INERT_WINDOW_METHODS 重复 声明(tsc 不报此错,但运行时解析失败:"has already been declared")。 --- src/proxy/captcha-happy.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/proxy/captcha-happy.ts b/src/proxy/captcha-happy.ts index 7efe44a..6490862 100644 --- a/src/proxy/captcha-happy.ts +++ b/src/proxy/captcha-happy.ts @@ -618,17 +618,22 @@ function makeScopedFunction(w) { /** * Wrap guest source so bare timer identifiers resolve to `w`'s registry. * - * The completion value MUST survive: happy-dom's JavaScriptCompiler hands - * `evaluateScript` a `(function anonymous($happy_dom){…})` expression and calls - * the returned function. A bare `with (…) { … }` statement evaluates to - * undefined, so the compiled script was silently never executed — every solve - * then timed out waiting for `initAliyunCaptcha`. Wrapping in an arrow-bodied - * IIFE that RETURNS the inner expression keeps both properties: the value flows - * out, and guest code still sits lexically inside the `with` scope. + * A bare `with (…) { … }` statement, not a function wrapper, and that choice + * carries both of the properties this needs: * - * Top-level `var`/`function` declarations keep their global-object semantics - * because `with` (unlike a function wrapper) introduces no variable - * environment of its own. + * - **Top-level declarations keep escaping.** `with` introduces an object + * environment, not a variable one, so guest `var`/`function` declarations + * still land on the global object. A function wrapper swallows them and + * `initAliyunCaptcha` never appears — every solve then timed out waiting + * for it (measured: ok=0 fail=3). + * + * - **The completion value still flows out.** happy-dom's JavaScriptCompiler + * hands `evaluateScript` a `(function anonymous($happy_dom){…})` expression + * and calls whatever comes back. `eval` yields a statement's completion + * value, and a block completes with its last expression statement, so the + * compiler's function expression is returned through the `with` unchanged. + * (Declarations produce no completion value, so a script ending in one is + * also fine — the preceding expression's value stands.) */ function wrapGuestSource(code, filename, scopeId) { const sourceUrl = filename && /^https?:/.test(String(filename)) ? `\n//# sourceURL=${filename}` : ""; @@ -1968,12 +1973,6 @@ const EXTRA_WINDOW_PROPS = [ // completes silently rather than raising a fatal ReferenceError. const INERT_WINDOW_METHODS = new Set(EXTRA_WINDOW_PROPS); -// Subset of the above that a real browser implements as no-op-ish window -// methods. When the tombstone expires these become harmless stubs instead of -// being deleted, so a straggling guest callback that still calls `moveBy()` -// completes silently rather than raising a fatal ReferenceError. -const INERT_WINDOW_METHODS = new Set(EXTRA_WINDOW_PROPS); - // Ref-count: the pool solves in parallel waves; each window must keep the // aliases alive until the LAST concurrent window is destroyed, otherwise one // destroyDom() pulls `window` out from under a sibling mid-solve.