diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 7532c9df0..0b2dc98eb 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -825,19 +825,19 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("Sandbox Test (sandbox_test.js)", async ({ context, extensionId }) => { + test("Sandbox Test (sandbox_function_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, - "sandbox_test.js", + "sandbox_function_test.js", `${gmApiMockServer.cspOrigin}/?SANDBOX_TEST_SC`, 8_000, { requireOrigin: gmApiMockServer.origin } ); - console.log(`[sandbox_test] passed=${passed}, failed=${failed}`); + console.log(`[sandbox_function_test] passed=${passed}, failed=${failed}`); if (failed !== 0) { - console.log("[sandbox_test] logs:", logs.join("\n")); + console.log("[sandbox_function_test] logs:", logs.join("\n")); } expect(failed, "Some sandbox tests failed").toBe(0); expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js new file mode 100644 index 000000000..e4c865b98 --- /dev/null +++ b/example/tests/sandbox_compatibility_test.js @@ -0,0 +1,1046 @@ +// ==UserScript== +// @name 沙盒全局相容性诊断器 +// @namespace https://github.com/scriptscat/scriptcat +// @version 1.0.0 +// @description 检查 window、this、globalThis、宿主函数/访问器、事件与 GM API 是否符合 Tampermonkey 沙盒预期 +// @author ScriptCat sandbox audit +// @match https://*/*?test_sandbox +// @run-at document-start +// @sandbox JavaScript +// @grant unsafeWindow +// @grant GM.info +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_deleteValue +// @grant GM.getValue +// @grant GM.setValue +// @grant GM.deleteValue +// @grant window.onurlchange +// @inject-into content +// ==/UserScript== + +/* + * 这个脚本是黑盒诊断器,不依赖 ScriptCat 内部模块。 + * 检查矩阵来自 scriptscat/scriptcat PR #1706 的 create_context.ts 与 Vitest: + * - realmGlobal 与 hostWindow 可以是两个 realm;realm own descriptor 优先,host 只补齐。 + * - 函数/访问器必须绑定正确 receiver;构造器和 interface 不可被 bind 剥掉 prototype/静态成员。 + * - 每个脚本独立复制 descriptor;window/self/globalThis 始终回到当前 sandbox。 + * - on* 属性用独立事件状态机模拟,函数替换不重复注册,this 指向 sandbox。 + * - 内部 context 字段不可泄漏,console 与 page console 应尽量隔离。 + * + * 这里明确申请了非 none 的 grant,并声明 @sandbox JavaScript;如果管理器仍把本脚本 + * 放在 page/main world,前面的隔离测试会直接报告 FAIL,而不是把 page world 当成成功。 + */ + +let observedThis = this; + +(function (topLevelThis) { + "use strict"; + + const UNAVAILABLE = Object.create(null); + const nativeObject = Object; + const nativeFunction = Function; + const nativeReflect = Reflect; + const nativeGetPrototypeOf = nativeObject.getPrototypeOf; + const nativeGetOwnPropertyDescriptor = nativeObject.getOwnPropertyDescriptor; + const nativeObjectToString = nativeObject.prototype.toString; + const nativeHasOwnProperty = nativeObject.prototype.hasOwnProperty; + + const safe = (fn) => { + try { + return { ok: true, value: fn() }; + } catch (error) { + return { ok: false, error }; + } + }; + + const read = (fn) => { + const result = safe(fn); + return result.ok ? result.value : UNAVAILABLE; + }; + + const hasOwn = (object, key) => { + try { + return nativeHasOwn.call(object, key); + } catch { + return false; + } + }; + + const sandboxGlobal = read(() => globalThis); + const sandboxWindow = read(() => window); + const sandboxSelf = read(() => self); + const pageWindow = read(() => (typeof unsafeWindow === "undefined" ? UNAVAILABLE : unsafeWindow)); + const gmObject = read(() => (typeof GM === "undefined" ? UNAVAILABLE : GM)); + const gmInfoObject = read(() => (typeof GM_info === "undefined" ? UNAVAILABLE : GM_info)); + + const valueType = (value) => { + if (value === UNAVAILABLE) return "不可用"; + if (value === undefined) return "undefined"; + if (value === null) return "null"; + return typeof value; + }; + + const formatError = (error) => { + if (error === UNAVAILABLE) return "不可用"; + if (error instanceof Error) return `${error.name}: ${error.message}`; + try { + return String(error); + } catch { + return "未知异常"; + } + }; + + const formatValue = (value) => { + if (value === UNAVAILABLE) return "<不可用>"; + if (value === sandboxWindow) return "sandbox window"; + if (value === sandboxGlobal) return "sandbox globalThis"; + if (value === sandboxSelf) return "sandbox self"; + if (value === pageWindow) return "page unsafeWindow"; + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (typeof value === "symbol") return String(value); + if (typeof value === "function") { + return `function ${value.name || "(anonymous)"}`; + } + try { + return nativeObjectToString.call(value); + } catch { + return `<${valueType(value)}>`; + } + }; + + const results = []; + + const addResult = ({ category, name, status, expected, actual, detail, required = true }) => { + results.push({ + category, + name, + status, + expected: expected || "", + actual: actual || "", + detail: detail || "", + required, + }); + }; + + const check = (category, name, predicate, expected, actual, detail, options = {}) => { + try { + const passed = Boolean(predicate()); + addResult({ + category, + name, + status: passed ? "PASS" : options.onFail || "FAIL", + expected, + actual: typeof actual === "function" ? actual() : actual, + detail: passed ? detail || "符合预期" : options.failDetail || detail || "不符合预期", + required: options.required !== false, + }); + } catch (error) { + addResult({ + category, + name, + status: options.onError || options.onFail || "FAIL", + expected, + actual: `抛出 ${formatError(error)}`, + detail: options.errorDetail || "检测过程抛出异常", + required: options.required !== false, + }); + } + }; + + const skip = (category, name, expected, detail) => { + addResult({ + category, + name, + status: "SKIP", + expected, + actual: "当前环境未提供", + detail, + required: false, + }); + }; + + const note = (category, name, expected, actual, detail) => { + addResult({ category, name, status: "INFO", expected, actual, detail, required: false }); + }; + + const descriptorFromChain = (object, key) => { + if (object === UNAVAILABLE || object === null || object === undefined) return null; + const visited = new Set(); + let current = object; + let depth = 0; + while (current && depth < 100 && !visited.has(current)) { + visited.add(current); + const descriptor = read(() => nativeGetOwnPropertyDescriptor(current, key)); + if (descriptor !== UNAVAILABLE && descriptor) { + return { descriptor, depth }; + } + const next = read(() => nativeGetPrototypeOf(current)); + if (next === UNAVAILABLE || next === current) break; + current = next; + depth += 1; + } + return null; + }; + + const descriptorSummary = (descriptor) => { + if (!descriptor) return "未找到 descriptor"; + const keys = []; + if (hasOwn(descriptor, "value")) keys.push(`value=${formatValue(descriptor.value)}`); + if (hasOwn(descriptor, "get")) keys.push(`get=${descriptor.get ? "yes" : "no"}`); + if (hasOwn(descriptor, "set")) keys.push(`set=${descriptor.set ? "yes" : "no"}`); + if (hasOwn(descriptor, "writable")) keys.push(`writable=${descriptor.writable}`); + keys.push(`enumerable=${Boolean(descriptor.enumerable)}`); + keys.push(`configurable=${Boolean(descriptor.configurable)}`); + return keys.join(", "); + }; + + const runDiagnostics = () => { + results.length = 0; + + if (sandboxWindow === UNAVAILABLE || sandboxGlobal === UNAVAILABLE) { + addResult({ + category: "启动", + name: "取得脚本全局对象", + status: "FAIL", + expected: "window 与 globalThis 可读", + actual: `window=${formatValue(sandboxWindow)}, globalThis=${formatValue(sandboxGlobal)}`, + detail: "当前上下文不像浏览器 userscript sandbox,后续检查只保留可执行项目。", + }); + return results; + } + + // 1. 全局对象与别名:这是 PR #1706 及 TM 半沙盒兼容性的最核心不变量。 + const aliases = [ + ["window === globalThis", () => sandboxWindow === sandboxGlobal, "sandbox window", () => formatValue(sandboxGlobal)], + ["window === self", () => sandboxWindow === sandboxSelf, "sandbox window", () => formatValue(sandboxSelf)], + ["window.window === window", () => read(() => sandboxWindow.window) === sandboxWindow, "sandbox window", () => formatValue(read(() => sandboxWindow.window))], + ["window.self === window", () => read(() => sandboxWindow.self) === sandboxWindow, "sandbox window", () => formatValue(read(() => sandboxWindow.self))], + [ + "window.globalThis === window", + () => read(() => sandboxWindow.globalThis) === sandboxWindow, + "sandbox window", + () => formatValue(read(() => sandboxWindow.globalThis)), + ], + [ + "globalThis.globalThis === globalThis", + () => read(() => sandboxGlobal.globalThis) === sandboxGlobal, + "sandbox globalThis", + () => formatValue(read(() => sandboxGlobal.globalThis)), + ], + ]; + for (const [name, predicate, expected, actual] of aliases) { + check("全局别名", name, predicate, expected, actual); + } + + check( + "全局别名", + "脚本最外层 this === window", + () => topLevelThis === sandboxWindow, + "sandbox window", + () => formatValue(topLevelThis), + "classic userscript 的顶层 this 应落在脚本沙盒;若这里失败,常见原因是被注入为 module/main world。" + ); + + const functionGlobal = read(() => nativeFunction("return this")()); + check( + "全局别名", + "Function(\"return this\")() === unsafeWindow", + () => functionGlobal === unsafeWindow, + "page unsafeWindow", + () => formatValue(functionGlobal), + "验证脚本 realm 的函数构造器能把 this 指向 page window。", + { onFail: "WARN", onError: "WARN", required: false } + ); + + const evalGlobal = read(() => + typeof sandboxWindow.eval === "function" ? sandboxWindow.eval("this") : UNAVAILABLE + ); + if (evalGlobal === UNAVAILABLE) { + skip("全局别名", "eval(\"this\")", "sandbox window", "eval 不可用或被管理器配置禁用。该项目不强制要求 eval 开启。"); + } else { + check( + "全局别名", + "window.eval(\"this\") === unsafeWindow", + () => evalGlobal === unsafeWindow, + "page unsafeWindow", + () => formatValue(evalGlobal), + "eval 的 receiver/realm 应逃到页面全局。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } + + const strictFunctionThis = read(() => + (function () { + return this; + })() + ); + check( + "全局别名", + "strict function 的 this === undefined", + () => strictFunctionThis === undefined, + "undefined", + () => formatValue(strictFunctionThis), + "这是语言层 sanity check,用来区分脚本的顶层 this 与严格函数 this。" + ); + + // 2. page window 隔离:@grant unsafeWindow 应明确产生 page/sandbox 两个对象。 + if (pageWindow === UNAVAILABLE) { + skip( + "页面隔离", + "unsafeWindow 可用", + "存在 page window", + "当前管理器没有提供 unsafeWindow;无法验证 page 与 sandbox 的身份边界。" + ); + } else { + check( + "页面隔离", + "sandbox window !== unsafeWindow", + () => sandboxWindow !== pageWindow, + "两个不同的 window 对象", + () => formatValue(pageWindow), + "若失败,脚本正在 page/main world 执行,或管理器没有真正建立沙盒。" + ); + check( + "页面隔离", + "globalThis !== unsafeWindow", + () => sandboxGlobal !== pageWindow, + "不同于 page window", + () => formatValue(sandboxGlobal), + "globalThis 不可回指页面全局。" + ); + check( + "页面隔离", + "self !== unsafeWindow", + () => sandboxSelf !== pageWindow, + "不同于 page window", + () => formatValue(sandboxSelf), + "self 不可回指页面全局。" + ); + + const pageSelf = read(() => pageWindow.self); + const pageGlobalThis = read(() => pageWindow.globalThis); + check( + "页面隔离", + "unsafeWindow.window === unsafeWindow", + () => read(() => pageWindow.window) === pageWindow, + "page unsafeWindow", + () => formatValue(read(() => pageWindow.window)), + "page 侧自身别名的 sanity check。", + { onFail: "WARN", onError: "WARN", required: false } + ); + check( + "页面隔离", + "unsafeWindow.self === unsafeWindow", + () => pageSelf === pageWindow, + "page unsafeWindow", + () => formatValue(pageSelf), + "page 侧自身别名的 sanity check。", + { onFail: "WARN", onError: "WARN", required: false } + ); + check( + "页面隔离", + "unsafeWindow.globalThis === unsafeWindow", + () => pageGlobalThis === pageWindow, + "page unsafeWindow", + () => formatValue(pageGlobalThis), + "page 侧自身别名的 sanity check。", + { onFail: "WARN", onError: "WARN", required: false } + ); + + // 写入随机 own property,验证 sandbox 写入不会落到 page window。 + const probeKey = `__tm_sandbox_audit_${Math.random().toString(36).slice(2)}`; + const probeValue = `sandbox-only-${Date.now()}`; + const pageHadKey = hasOwn(pageWindow, probeKey); + let sandboxWriteResult = UNAVAILABLE; + let pageValueAfterWrite = UNAVAILABLE; + try { + sandboxWriteResult = safe(() => { + sandboxWindow[probeKey] = probeValue; + return sandboxWindow[probeKey]; + }); + sandboxWriteResult = sandboxWriteResult.ok ? sandboxWriteResult.value : UNAVAILABLE; + pageValueAfterWrite = read(() => pageWindow[probeKey]); + check( + "页面隔离", + "sandbox 普通属性写入不泄漏到 page", + () => sandboxWriteResult === probeValue && pageValueAfterWrite !== probeValue, + `sandbox[${probeKey}] 可读,page 不可见`, + () => `sandbox=${formatValue(sandboxWriteResult)}, page=${formatValue(pageValueAfterWrite)}`, + "这是最直接的 page/sandbox 状态隔离测试。" + ); + } finally { + safe(() => delete sandboxWindow[probeKey]); + if (!pageHadKey && read(() => pageWindow[probeKey]) === probeValue) { + safe(() => delete pageWindow[probeKey]); + } + } + } + + // 3. top/parent/frames:顶层 frame 应折回 sandbox;iframe 的非自身引用允许保留。 + if (pageWindow === UNAVAILABLE) { + skip("窗口层级别名", "top / parent / frames", "符合当前 frame 的层级语义", "缺少 unsafeWindow,无法判断当前 page frame。"); + } else { + for (const key of ["top", "parent", "frames"]) { + const pageValue = read(() => pageWindow[key]); + const sandboxValue = read(() => sandboxWindow[key]); + if (pageValue === UNAVAILABLE || sandboxValue === UNAVAILABLE) { + skip("窗口层级别名", `window.${key}`, "可读", "该属性在当前浏览器不可用。"); + } else if (pageValue === pageWindow) { + check( + "窗口层级别名", + `顶层 frame: window.${key} === window`, + () => sandboxValue === sandboxWindow, + "sandbox window", + () => formatValue(sandboxValue), + "PR #1706 的自引用折返规则:hostWindow 自指时返回当前 mySandbox。" + ); + } else { + note( + "窗口层级别名", + `iframe: window.${key} 保留非自身引用`, + "不要错误地把非自身 frame 强行改成当前 sandbox", + formatValue(sandboxValue), + "当前值来自父/top/frame realm;PR 的测试允许这类非自身引用保留,具体 identity 由管理器实现决定。" + ); + } + } + } + + // 4. realm intrinsics:split-realm 时,JavaScript 内建来自脚本 realm,不能被 hostWindow 覆盖。 + const intrinsicEntries = [ + ["Object", () => Object], + ["Function", () => Function], + ["Array", () => Array], + ["String", () => String], + ["Number", () => Number], + ["Boolean", () => Boolean], + ["RegExp", () => RegExp], + ["Date", () => Date], + ["Error", () => Error], + ["Promise", () => Promise], + ["Map", () => Map], + ["Set", () => Set], + ["Symbol", () => Symbol], + ["BigInt", () => BigInt], + ["JSON", () => JSON], + ["Math", () => Math], + ["Reflect", () => Reflect], + ]; + for (const [name, getIdentifier] of intrinsicEntries) { + const identifier = read(getIdentifier); + if (identifier === UNAVAILABLE) { + skip("脚本 realm 内建", `window.${name}`, `与脚本中的 ${name} 一致`, "当前 JavaScript 引擎未提供该内建对象。"); + continue; + } + const windowValue = read(() => sandboxWindow[name]); + check( + "脚本 realm 内建", + `window.${name} === ${name}`, + () => windowValue === identifier, + formatValue(identifier), + () => formatValue(windowValue), + "realmGlobal own descriptor 优先;同一脚本 realm 的全局别名应保持一致。" + ); + } + + check( + "脚本 realm 内建", + "Object.prototype.toString.call(window)", + () => { + const tag = read(() => nativeObjectToString.call(sandboxWindow)); + return tag === "[object Window]" || tag === "[object global]" || tag === "[object Object]"; + }, + "[object Window](或管理器等价 tag)", + () => formatValue(read(() => nativeObjectToString.call(sandboxWindow))), + "不同浏览器/管理器对 pseudo-window 的 toStringTag 可能不同;异常 tag 仍值得检查。", + { onFail: "WARN", onError: "WARN", required: false } + ); + + const prototypeOfWindow = read(() => nativeGetPrototypeOf(sandboxWindow)); + note( + "脚本 realm 内建", + "window 的 prototype 形态", + "PR 的 PseudoWindow 路径为 null prototype;其他 TM 模式可为 Window prototype", + formatValue(prototypeOfWindow), + "prototype 形态是实现策略证据,不单独决定兼容性;真正关键的是 own descriptor 与身份/receiver 测试。" + ); + + // 5. 宿主属性与 descriptor:读取 getter 不应抛异常,关键函数要可以被抽出调用。 + const hostProperties = [ + ["document", "object"], + ["location", "object"], + ["navigator", "object"], + ["history", "object"], + ["setTimeout", "function"], + ["clearTimeout", "function"], + ["addEventListener", "function"], + ["removeEventListener", "function"], + ["dispatchEvent", "function"], + ]; + for (const [key, expectedType] of hostProperties) { + const value = read(() => sandboxWindow[key]); + check( + "宿主属性", + `window.${key} 可读且类型正确`, + () => value !== UNAVAILABLE && value !== null && typeof value === expectedType, + expectedType, + () => `${formatValue(value)} (${valueType(value)})`, + "宿主属性由 hostWindow 补齐,访问器必须以真实 hostWindow 为 receiver。" + ); + } + + for (const key of ["document", "location", "navigator", "setTimeout", "addEventListener", "onmessage"]) { + const found = descriptorFromChain(sandboxWindow, key); + if (!found) { + note("描述符检查", `window.${key} descriptor`, "可由 own 或原型链取得", "未找到", "属性可能由管理器以其他方式提供。"); + } else { + note( + "描述符检查", + `window.${key} descriptor`, + "保留 descriptor 语义,不把 getter 提前读成 value", + `prototype depth=${found.depth}; ${descriptorSummary(found.descriptor)}`, + "PR #1706 使用 descriptor map,避免普通赋值触发 getter 或丢失 enumerable/configurable/writable。" + ); + } + } + + const extractedSetTimeout = read(() => sandboxWindow.setTimeout); + const extractedClearTimeout = read(() => sandboxWindow.clearTimeout); + if (typeof extractedSetTimeout !== "function" || typeof extractedClearTimeout !== "function") { + skip("宿主 receiver", "抽出 setTimeout/clearTimeout 后调用", "不抛 Illegal invocation", "当前上下文没有可调用的定时器。"); + } else { + let timerId = UNAVAILABLE; + let timerError = null; + try { + // 故意以裸函数调用测试:正确实现应已把函数绑定到 hostWindow。 + timerId = extractedSetTimeout(() => undefined, 0); + extractedClearTimeout(timerId); + } catch (error) { + timerError = error; + } + check( + "宿主 receiver", + "抽出 setTimeout/clearTimeout 后裸调用", + () => timerError === null, + "不抛 Illegal invocation", + () => (timerError ? `抛出 ${formatError(timerError)}` : `timer id=${formatValue(timerId)}`), + "覆盖 PR 的 materializeDescriptor/bindFn 路径。" + ); + } + + const extractedAddEventListener = read(() => sandboxWindow.addEventListener); + const extractedRemoveEventListener = read(() => sandboxWindow.removeEventListener); + const extractedDispatchEvent = read(() => sandboxWindow.dispatchEvent); + const eventConstructor = read(() => sandboxWindow.Event); + if ( + typeof extractedAddEventListener !== "function" || + typeof extractedRemoveEventListener !== "function" || + typeof extractedDispatchEvent !== "function" || + typeof eventConstructor !== "function" + ) { + skip("宿主 receiver", "抽出 EventTarget 方法后调用", "add/remove/dispatch 均可工作", "当前环境缺少完整 EventTarget API。"); + } else { + const eventName = `tm-sandbox-audit-${Math.random().toString(36).slice(2)}`; + let calls = 0; + let eventError = null; + const listener = () => { + calls += 1; + }; + try { + // 同样故意裸调用,验证 hostWindow receiver 已绑定。 + extractedAddEventListener(eventName, listener, false); + const event = new eventConstructor(eventName); + extractedDispatchEvent(event); + extractedRemoveEventListener(eventName, listener, false); + } catch (error) { + eventError = error; + } + check( + "宿主 receiver", + "抽出 add/remove/dispatchEvent 后裸调用", + () => eventError === null && calls === 1, + "无异常且 listener 恰好触发一次", + () => (eventError ? `抛出 ${formatError(eventError)}` : `listener calls=${calls}`), + "覆盖 Firefox split-realm 中只能经 hostWindow 原型链取得 EventTarget 方法的场景。" + ); + safe(() => extractedRemoveEventListener(eventName, listener, false)); + } + + // 6. 构造器/interface 与静态成员:绝不能把 bind 产物误当作普通宿主函数。 + const constructorChecks = [ + ["EventTarget", "prototype"], + ["Event", "prototype"], + ["Node", "prototype"], + ["HTMLElement", "prototype"], + ["XMLHttpRequest", "prototype"], + ]; + for (const [name] of constructorChecks) { + const value = read(() => sandboxWindow[name]); + if (value === UNAVAILABLE || typeof value !== "function") { + skip("构造器与 interface", `window.${name}`, "函数且保留 prototype", "浏览器可能不提供该接口,或管理器未暴露该成员。"); + } else { + check( + "构造器与 interface", + `${name} 保留 prototype`, + () => "prototype" in value && value.prototype !== undefined, + "存在 prototype", + () => ("prototype" in value ? formatValue(value.prototype) : "无 prototype"), + "isConstructorOrInterface 应跳过 bind,保留构造器原型与静态成员。" + ); + } + } + + const node = read(() => sandboxWindow.Node); + if (node !== UNAVAILABLE && typeof node === "function") { + check( + "构造器与 interface", + "Node.ELEMENT_NODE 静态常量", + () => node.ELEMENT_NODE === 1, + "1", + () => formatValue(node.ELEMENT_NODE), + "验证 Node 没有被错误 bind 成丢失静态成员的函数。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } else { + skip("构造器与 interface", "Node.ELEMENT_NODE 静态常量", "1", "当前浏览器未提供 Node。"); + } + + const nodeFilter = read(() => sandboxWindow.NodeFilter); + if (nodeFilter !== UNAVAILABLE && nodeFilter !== null) { + check( + "构造器与 interface", + "NodeFilter.SHOW_TEXT 静态常量", + () => nodeFilter.SHOW_TEXT === 4, + "4", + () => formatValue(nodeFilter.SHOW_TEXT), + "覆盖没有 prototype 但以大写名称识别的 interface。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } else { + skip("构造器与 interface", "NodeFilter.SHOW_TEXT 静态常量", "4", "当前浏览器未提供 NodeFilter。"); + } + + const math = read(() => sandboxWindow.Math); + const number = read(() => sandboxWindow.Number); + const object = read(() => sandboxWindow.Object); + check( + "构造器与 interface", + "Math.max(2, 7) === 7", + () => math !== UNAVAILABLE && math.max(2, 7) === 7, + "7", + () => formatValue(read(() => math.max(2, 7))), + "验证 realm 内建静态方法仍可调用。" + ); + check( + "构造器与 interface", + "Number.isNaN(NaN) === true", + () => number !== UNAVAILABLE && number.isNaN(NaN) === true, + "true", + () => formatValue(read(() => number.isNaN(NaN))), + "验证 Firefox Xray 下的内建静态成员没有被剥掉。" + ); + check( + "构造器与 interface", + "Object.isFrozen(Object.freeze({})) === true", + () => object !== UNAVAILABLE && object.isFrozen(object.freeze({})) === true, + "true", + () => formatValue(read(() => object.isFrozen(object.freeze({})))), + "验证 Object 的静态方法与 descriptor 复制正常。" + ); + + // 7. on* 事件属性:模拟 createEventProp 的函数/对象/清空状态机。 + const eventPropertyCandidates = ["onmessage", "onhashchange", "onresize", "onfocus"]; + const eventProperty = eventPropertyCandidates.find((key) => { + const found = descriptorFromChain(sandboxWindow, key); + return found && (found.descriptor.get || found.descriptor.set); + }); + if (!eventProperty || typeof extractedDispatchEvent !== "function" || typeof eventConstructor !== "function") { + skip("on* 事件状态机", "on* 属性函数替换与 this", "handler 只触发一次且 this===sandbox", "当前环境没有可用的 on* accessor。"); + } else { + const eventName = eventProperty.slice(2); + const oldValue = read(() => sandboxWindow[eventProperty]); + let firstCalls = 0; + let secondCalls = 0; + let objectCalls = 0; + let eventError = null; + const firstHandler = function () { + firstCalls += 1; + }; + const secondHandler = function () { + secondCalls += 1; + }; + const objectHandler = { handleEvent: () => (objectCalls += 1) }; + try { + nativeReflect.set(sandboxWindow, eventProperty, null, sandboxWindow); + nativeReflect.set(sandboxWindow, eventProperty, firstHandler, sandboxWindow); + nativeReflect.set(sandboxWindow, eventProperty, secondHandler, sandboxWindow); + extractedDispatchEvent(new eventConstructor(eventName)); + nativeReflect.set(sandboxWindow, eventProperty, objectHandler, sandboxWindow); + extractedDispatchEvent(new eventConstructor(eventName)); + nativeReflect.set(sandboxWindow, eventProperty, null, sandboxWindow); + extractedDispatchEvent(new eventConstructor(eventName)); + } catch (error) { + eventError = error; + } finally { + safe(() => nativeReflect.set(sandboxWindow, eventProperty, oldValue === UNAVAILABLE ? null : oldValue, sandboxWindow)); + } + check( + "on* 事件状态机", + `${eventProperty}: 函数替换只调用新 handler`, + () => eventError === null && firstCalls === 0 && secondCalls === 1, + "旧函数 0 次,新函数 1 次", + () => (eventError ? `抛出 ${formatError(eventError)}` : `old=${firstCalls}, new=${secondCalls}`), + "function → function 时不应重复注册,也不应保留旧 callback。" + ); + check( + "on* 事件状态机", + `${eventProperty}: handler 的 this === sandbox window`, + () => observedThis === sandboxWindow, + "sandbox window", + () => formatValue(observedThis), + "createEventProp 的 handleEvent 必须使用 fn.call(mySandbox, event)。" + ); + check( + "on* 事件状态机", + `${eventProperty}: object handler 不注册为函数监听`, + () => objectCalls === 0, + "object handler 不被调用", + () => `object calls=${objectCalls}`, + "对象/Symbol 等非 function 值可以保存,但不应被当成真实 on* callback。" + ); + } + + // 8. window.onurlchange 是独立 grant 通道;只验证 accessor,不主动导航页面。 + const onUrlChangeDescriptor = descriptorFromChain(sandboxWindow, "onurlchange"); + const currentOnUrlChange = read(() => sandboxWindow.onurlchange); + if (!onUrlChangeDescriptor && (currentOnUrlChange === UNAVAILABLE || currentOnUrlChange === undefined)) { + skip("特殊 GM/window 能力", "window.onurlchange accessor", "可读写函数/null", "当前管理器未提供该可选 API。"); + } else { + const oldOnUrlChange = read(() => sandboxWindow.onurlchange); + let onUrlChangeReadback = UNAVAILABLE; + const onUrlChangeHandler = () => undefined; + const result = safe(() => { + nativeReflect.set(sandboxWindow, "onurlchange", onUrlChangeHandler, sandboxWindow); + onUrlChangeReadback = sandboxWindow.onurlchange; + nativeReflect.set(sandboxWindow, "onurlchange", null, sandboxWindow); + }); + safe(() => nativeReflect.set(sandboxWindow, "onurlchange", oldOnUrlChange === UNAVAILABLE ? null : oldOnUrlChange, sandboxWindow)); + check( + "特殊 GM/window 能力", + "window.onurlchange 可写入函数并读回", + () => result.ok && onUrlChangeReadback === onUrlChangeHandler, + "读回同一 handler", + () => (result.ok ? formatValue(onUrlChangeReadback) : `抛出 ${formatError(result.error)}`), + "PR #1706 将 onurlchange 作为 context 哨兵与 hostWindow 自定义事件通道处理。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } + + // 9. ScriptCat 保护字段与模块变量遮蔽;泄漏这些字段通常意味着复制 context 时 protect 失效。 + for (const key of [ + "runFlag", + "message", + "contentMsg", + "scriptRes", + "grantSet", + "valueChangeListener", + "EE", + "loadScriptPromise", + "loadScriptResolve", + ]) { + const value = read(() => sandboxWindow[key]); + check( + "能力边界", + `内部字段 ${key} 不泄漏`, + () => value === UNAVAILABLE || value === undefined, + "undefined", + () => formatValue(value), + "复制 GM context 到脚本 window 时应跳过 protect 集合。" + ); + } + for (const key of ["define", "module", "exports"]) { + const value = read(() => sandboxWindow[key]); + check( + "能力边界", + `模块变量 ${key} 被遮蔽为 undefined`, + () => value === undefined || value === UNAVAILABLE, + "undefined", + () => formatValue(value), + "PR 的 createProxyContext 会主动遮蔽这些名称,避免脚本执行环境误用外部模块对象。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } + + // 10. console 与 GM API:不改变持久化数据,只检查暴露面与隔离证据。 + const sandboxConsole = read(() => sandboxWindow.console); + if (pageWindow !== UNAVAILABLE) { + const pageConsole = read(() => pageWindow.console); + check( + "能力边界", + "sandbox console 与 page console 分离", + () => sandboxConsole !== UNAVAILABLE && sandboxConsole !== pageConsole, + "不同 console 对象(推荐)", + () => `sandbox=${formatValue(sandboxConsole)}, page=${formatValue(pageConsole)}`, + "PR #1706 从 ConsolePrototype/descriptor clone 建立沙盒 console;不同管理器可能共享 console,因此此项是警告级。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } else { + skip("能力边界", "sandbox console 与 page console 分离", "不同 console 对象", "缺少 unsafeWindow。"); + } + + const gmPairs = [ + ["GM_getValue", "GM.getValue"], + ["GM_setValue", "GM.setValue"], + ["GM_deleteValue", "GM.deleteValue"], + ]; + /* + // ignore this test + for (const [legacyName, modernName] of gmPairs) { + const legacy = read(() => globalThis[legacyName]); + const modern = read(() => (gmObject === UNAVAILABLE ? UNAVAILABLE : gmObject[modernName.slice(3)])); + if (legacy === UNAVAILABLE && modern === UNAVAILABLE) { + skip("GM 能力命名", `${legacyName} ↔ ${modernName}`, "两种命名均可用", "当前管理器未提供此 grant,或尚未实现现代/旧式 API。"); + } else { + check( + "GM 能力命名", + `${legacyName} ↔ ${modernName}`, + () => typeof legacy === "function" && typeof modern === "function", + "legacy 与 modern 都是 function", + () => `legacy=${formatValue(legacy)}, modern=${formatValue(modern)}`, + "对应 createContext 的 GM.* / GM_* 双命名注入;本检查不读写任何持久化值。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } + } + */ + + const modernInfo = read(() => (gmObject === UNAVAILABLE ? UNAVAILABLE : gmObject.info)); + const legacyInfo = gmInfoObject; + if (modernInfo === UNAVAILABLE && legacyInfo === UNAVAILABLE) { + skip("GM 能力命名", "GM.info / GM_info", "至少有一个信息对象", "当前管理器没有暴露脚本元数据 API。"); + } else { + check( + "GM 能力命名", + "GM.info 或 GM_info 可读", + () => (modernInfo !== UNAVAILABLE && modernInfo !== undefined) || (legacyInfo !== UNAVAILABLE && legacyInfo !== undefined), + "信息对象", + () => `GM.info=${formatValue(modernInfo)}, GM_info=${formatValue(legacyInfo)}`, + "只读取元数据,不执行任何高权限 API。", + { onFail: "WARN", onError: "WARN", required: false } + ); + } + + return results; + }; + + const summaryOf = (items) => { + const summary = { PASS: 0, FAIL: 0, WARN: 0, SKIP: 0, INFO: 0 }; + for (const item of items) summary[item.status] = (summary[item.status] || 0) + 1; + summary.overall = summary.FAIL ? "FAIL" : summary.WARN ? "WARN" : "PASS"; + return summary; + }; + + let currentResults = runDiagnostics(); + + const managerName = read(() => { + const info = gmObject !== UNAVAILABLE && gmObject.info ? gmObject.info : gmInfoObject; + return info && info.scriptHandler ? `${info.scriptHandler}${info.version ? ` ${info.version}` : ""}` : "未知管理器"; + }); + + const pageUrl = read(() => sandboxWindow.location && sandboxWindow.location.href); + + const renderValue = (value) => (value === undefined || value === null ? "" : String(value)); + + const makeReport = () => { + const summary = summaryOf(currentResults); + return JSON.stringify( + { + tool: "sandbox-global-compatibility-audit", + version: "1.0.0", + time: new Date().toISOString(), + url: pageUrl === UNAVAILABLE ? undefined : pageUrl, + manager: managerName === UNAVAILABLE ? undefined : managerName, + sandboxWindow: formatValue(sandboxWindow), + unsafeWindow: formatValue(pageWindow), + summary, + tests: currentResults, + }, + null, + 2 + ); + }; + + const logSummary = () => { + const logger = read(() => sandboxWindow.console) !== UNAVAILABLE ? sandboxWindow.console : console; + const summary = summaryOf(currentResults); + safe(() => logger.log(`[sandbox audit] ${summary.overall}`, summary)); + safe(() => logger.table(currentResults.map(({ category, name, status, expected, actual }) => ({ category, name, status, expected, actual })))); + }; + + const mountPanel = () => { + const documentObject = read(() => document); + if (documentObject === UNAVAILABLE) return; + + const attach = () => { + const documentElement = read(() => documentObject.documentElement); + if (!documentElement) return; + + const host = documentObject.createElement("div"); + host.id = "__tm_sandbox_global_audit__"; + host.setAttribute("data-sandbox-audit", "true"); + host.style.cssText = "all:initial;position:fixed;top:12px;right:12px;z-index:2147483647;"; + documentElement.appendChild(host); + + const root = typeof host.attachShadow === "function" ? host.attachShadow({ mode: "open" }) : host; + root.innerHTML = ` + +
+
+
沙盒全局相容性诊断器
+ + + + +
+
+
+
FAIL 表示核心沙盒不变量失败;WARN 多为管理器/浏览器差异或可选 API;SKIP 表示环境不提供该项目。请先看“页面隔离”和“全局别名”。
+
状态检查实际预期说明
+
模型:ScriptCat PR #1706 create_context.ts + Vitest;目标:兼容 Tampermonkey 非 none sandbox。
+
+
`; + + const query = (selector) => root.querySelector(selector); + const sub = query("#sub"); + const overall = query("#overall"); + const summary = query("#summary"); + const rows = query("#rows"); + const body = query("#body"); + const copyButton = query("#copy"); + const rerunButton = query("#rerun"); + const collapseButton = query("#collapse"); + + const render = () => { + const counts = summaryOf(currentResults); + sub.textContent = `${managerName === UNAVAILABLE ? "未知管理器" : managerName} · ${pageUrl === UNAVAILABLE ? "当前 URL 不可读" : pageUrl}`; + overall.textContent = counts.overall; + overall.className = `pill overall-${counts.overall}`; + summary.textContent = ""; + for (const key of ["PASS", "FAIL", "WARN", "SKIP", "INFO"]) { + const pill = documentObject.createElement("span"); + pill.className = `pill status-${key}`; + pill.textContent = `${key} ${counts[key] || 0}`; + summary.appendChild(pill); + } + rows.textContent = ""; + let previousCategory = null; + for (const item of currentResults) { + if (item.category !== previousCategory) { + const categoryRow = documentObject.createElement("tr"); + categoryRow.className = "category"; + const categoryCell = documentObject.createElement("td"); + categoryCell.colSpan = 5; + categoryCell.textContent = item.category; + categoryRow.appendChild(categoryCell); + rows.appendChild(categoryRow); + previousCategory = item.category; + } + const row = documentObject.createElement("tr"); + const statusCell = documentObject.createElement("td"); + statusCell.className = `status-${item.status}`; + statusCell.textContent = item.status; + const nameCell = documentObject.createElement("td"); + nameCell.textContent = item.name; + const actualCell = documentObject.createElement("td"); + actualCell.textContent = renderValue(item.actual); + const expectedCell = documentObject.createElement("td"); + expectedCell.textContent = renderValue(item.expected); + const detailCell = documentObject.createElement("td"); + detailCell.textContent = renderValue(item.detail); + row.append(statusCell, nameCell, actualCell, expectedCell, detailCell); + rows.appendChild(row); + } + }; + + const fallbackCopy = (text) => { + const textarea = documentObject.createElement("textarea"); + textarea.value = text; + textarea.style.cssText = "position:fixed;left:-9999px;top:-9999px;"; + documentElement.appendChild(textarea); + textarea.select(); + const result = safe(() => documentObject.execCommand("copy")); + textarea.remove(); + return result.ok && result.value !== false; + }; + + copyButton.addEventListener("click", () => { + const text = makeReport(); + const clipboard = read(() => sandboxWindow.navigator && sandboxWindow.navigator.clipboard); + const writeResult = clipboard && typeof clipboard.writeText === "function" ? safe(() => clipboard.writeText(text)) : null; + if (writeResult && writeResult.ok && writeResult.value && typeof writeResult.value.then === "function") { + writeResult.value.then(() => { + copyButton.textContent = "已复制"; + setTimeout(() => (copyButton.textContent = "复制 JSON"), 1200); + }).catch(() => { + copyButton.textContent = fallbackCopy(text) ? "已复制" : "复制失败"; + }); + } else { + copyButton.textContent = fallbackCopy(text) ? "已复制" : "复制失败"; + } + }); + + rerunButton.addEventListener("click", () => { + currentResults = runDiagnostics(); + render(); + logSummary(); + }); + + collapseButton.addEventListener("click", () => { + const collapsed = body.classList.toggle("hidden"); + collapseButton.textContent = collapsed ? "展开" : "收起"; + }); + + render(); + logSummary(); + }; + + if (documentObject.documentElement) { + attach(); + } else { + documentObject.addEventListener("DOMContentLoaded", attach, { once: true }); + } + }; + + mountPanel(); +})(this); diff --git a/example/tests/sandbox_test.js b/example/tests/sandbox_function_test.js similarity index 100% rename from example/tests/sandbox_test.js rename to example/tests/sandbox_function_test.js diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 1966ddf08..b242ba73d 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -1,7 +1,261 @@ -import { afterEach, describe, it, expect, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; -import { createContext, createProxyContext, shouldFnBind } from "./create_context"; +import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; + +type AnyRecord = Record; + +const REALM_INTRINSIC_KEYS = [ + "Object", + "Function", + "Array", + "String", + "Number", + "Boolean", + "RegExp", + "Date", + "Error", + "Promise", + "Map", + "Set", + "Symbol", + "BigInt", + "JSON", + "Math", + "Reflect", +] as const; + +/** + * 這個 event target 只實作 createProxyContext 真正依賴的 contract。 + * 它不依賴 happy-dom 的 Window、WindowProxy 或原生事件實作,讓事件測試只驗證 + * createEventProp 的註冊/移除/handleEvent 狀態機。 + */ +class DeterministicEventTarget { + private readonly listeners = new Map>(); + + addEventListener(type: string, listener: EventListener | EventListenerObject | null) { + if (!listener) return; + let entries = this.listeners.get(type); + if (!entries) this.listeners.set(type, (entries = new Set())); + entries.add(listener); + } + + removeEventListener(type: string, listener: EventListener | EventListenerObject | null) { + if (!listener) return; + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: Event) { + for (const listener of [...(this.listeners.get(event.type) || [])]) { + if (typeof listener === "function") listener.call(undefined, event); + else listener.handleEvent(event); + } + return true; + } + + listenerCount(type: string) { + return this.listeners.get(type)?.size || 0; + } +} + +const createSplitRealmRoots = () => { + const eventTarget = new DeterministicEventTarget(); + const realmGlobal = Object.create(null) as AnyRecord; + const hostWindowPrototype = Object.create(null) as AnyRecord; + const hostWindow = Object.create(hostWindowPrototype) as AnyRecord; + + // PseudoWindow 的 identity/brand 證據。 + hostWindow[Symbol.toStringTag] = "Window"; + hostWindow.constructor = function HostWindow() {}; + Object.defineProperty(hostWindow, "__proto__", { + configurable: true, + enumerable: false, + value: hostWindowPrototype, + writable: false, + }); + + // 用 object literal method 產生「無 prototype、但不是 native」的 host method。 + // createGlobalSnapshot 會把它放入 protoBaseDescs,materializeDescriptor 再綁定 hostWindow。 + const hostMethods = { + addEventListener(type: string, listener: any) { + return eventTarget.addEventListener(type, listener); + }, + removeEventListener(type: string, listener: any) { + return eventTarget.removeEventListener(type, listener); + }, + dispatchEvent(event: Event) { + return eventTarget.dispatchEvent(event); + }, + dynamicPrototypeMethod(this: unknown) { + return this; + }, + }; + hostWindowPrototype.addEventListener = hostMethods.addEventListener; + hostWindowPrototype.removeEventListener = hostMethods.removeEventListener; + hostWindowPrototype.dispatchEvent = hostMethods.dispatchEvent; + hostWindowPrototype.dynamicPrototypeMethod = hostMethods.dynamicPrototypeMethod; + Object.defineProperty(hostWindowPrototype, "dynamicPrototypeValue", { + configurable: true, + enumerable: true, + value: { source: "prototype" }, + writable: false, + }); + + // 兩個 root 都有,但刻意讓 realmGlobal own descriptor 勝出。 + const runtimeGlobal = globalThis as AnyRecord; + for (const key of REALM_INTRINSIC_KEYS) { + realmGlobal[key] = runtimeGlobal[key]; + } + realmGlobal.realmOnly = "realm-value"; + Object.defineProperty(realmGlobal, "realmAccessor", { + configurable: true, + enumerable: true, + get() { + return this === realmGlobal ? "realm-receiver" : "wrong-receiver"; + }, + }); + + const TestEvent = class TestEvent { + type: string; + + constructor(type: string) { + this.type = type; + } + }; + hostWindow.Event = TestEvent; + hostWindow.EventTarget = class HostEventTarget {}; + + // 只有必要的 document 保留為 host own;普通動態頁面屬性放在 prototype, + // 避免和 hostOnlySecret 的隔離負向 regression 混成同一種 fixture。 + hostWindow.document = { owner: hostWindow }; + hostWindowPrototype.dynamicHostValue = { source: "host" }; + hostWindowPrototype.dynamicHostMethod = { + dynamicHostMethod(this: unknown) { + return this; + }, + }.dynamicHostMethod; + + let dynamicHostAccessorValue = "host-value"; + Object.defineProperty(hostWindowPrototype, "dynamicHostAccessor", { + configurable: true, + enumerable: true, + get() { + return this === hostWindow ? dynamicHostAccessorValue : "wrong-receiver"; + }, + set(value) { + dynamicHostAccessorValue = this === hostWindow ? value : "wrong-receiver"; + }, + }); + hostWindowPrototype.hostNativeCallable = Object.prototype.valueOf; + + Object.defineProperty(hostWindowPrototype, "location", { + configurable: true, + enumerable: true, + get() { + return this === hostWindow ? { href: "https://example.test/" } : undefined; + }, + set() { + // setter 只用來驗證 receiver;不改變 location object。 + }, + }); + + const NodeLike = function NodeLike() {}; + (NodeLike as AnyRecord).ELEMENT_NODE = 1; + hostWindow.NodeLike = NodeLike; + + const FilterLike = () => undefined; + (FilterLike as AnyRecord).SHOW_TEXT = 4; + hostWindow.FilterLike = FilterLike; + + const XMLHttpRequestLike = class XMLHttpRequestLike {}; + (XMLHttpRequestLike as AnyRecord).DONE = 4; + hostWindow.XMLHttpRequestLike = XMLHttpRequestLike; + + Object.defineProperty(hostWindow, "onload", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + Object.defineProperty(hostWindow, "oncustomcompat", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + Object.defineProperty(hostWindowPrototype, "onmessage", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + + hostWindow.window = hostWindow; + hostWindow.self = hostWindow; + hostWindow.globalThis = hostWindow; + hostWindow.top = hostWindow; + hostWindow.parent = hostWindow; + hostWindow.frames = hostWindow; + + const roots: RealmRoots = { realmGlobal, hostWindow }; + return { roots, eventTarget, hostWindow, hostWindowPrototype, realmGlobal, TestEvent }; +}; + +const createSharedRealmRoots = () => { + const fixture = createSplitRealmRoots(); + const runtimeGlobal = globalThis as AnyRecord; + for (const key of REALM_INTRINSIC_KEYS) { + fixture.hostWindow[key] = runtimeGlobal[key]; + } + + return { + ...fixture, + roots: { + realmGlobal: fixture.hostWindow, + hostWindow: fixture.hostWindow, + } as RealmRoots, + }; +}; + +const createDefaultRootWindow = () => createSplitRealmRoots(); + +const createNavigableWindowFixture = () => { + const fixture = createDefaultRootWindow(); + const navigation = new DeterministicEventTarget(); + let href = "https://example.test/"; + const location = Object.create(null) as AnyRecord; + Object.defineProperty(location, "href", { + configurable: true, + enumerable: true, + get: () => href, + set: (value: string) => { + href = value; + }, + }); + // createSplitRealmRoots 的 prototype 上有只讀用的 location accessor;這裡必須 + // 明確建立 own data property,否則普通賦值只會命中 inherited setter。 + Object.defineProperty(fixture.hostWindow, "location", { + configurable: true, + enumerable: true, + value: location, + writable: true, + }); + fixture.hostWindow.navigation = navigation; + return { ...fixture, location, navigation }; +}; + +const createProxyFixture = (extra: AnyRecord = {}) => { + const fixture = createSplitRealmRoots(); + const context = Object.assign(Object.create(null), { + window: Object.create(null), + ...extra, + }); + return { + ...fixture, + context, + sandbox: createProxyContext(context, fixture.roots), + }; +}; const createScriptInfo = (metadata: Record = {}): TScriptInfo => ({ @@ -32,88 +286,67 @@ const createTestContext = (grants: string[], metadata: Record new Set(grants) ); -describe.concurrent("shouldFnBind", () => { - it.concurrent("不处理非原生函数", () => { - const o: Record = {}; - o.targetArrowFn = () => {}; - expect(shouldFnBind(o.targetArrowFn)).toBe(false); - o.targetArrowFn = new Proxy(o.targetArrowFn, {}); - expect(shouldFnBind(o.targetArrowFn)).toBe(false); - o.targetFn1 = function () {}; - expect(shouldFnBind(o.targetFn1)).toBe(false); - o.targetFn1 = new Proxy(o.targetFn1, {}); - expect(shouldFnBind(o.targetFn1)).toBe(false); - o.targetFn2 = function targetFn2() {}; - expect(shouldFnBind(o.targetFn2)).toBe(false); - o.targetFn2 = new Proxy(o.targetFn2, {}); - expect(shouldFnBind(o.targetFn2)).toBe(false); - }); - it.concurrent("处理Proxy Function #985", () => { - const o: Record = {}; - // 例1: valueOf - o.valueOf = global.valueOf; - expect(shouldFnBind(o.valueOf)).toBe(true); - o.valueOf = new Proxy(o.valueOf, {}); - expect(shouldFnBind(o.valueOf)).toBe(true); - // 例2: setTimeoutForTest1: 验证一次拦截 - // @ts-ignore - o.setTimeoutForTest1 = global.setTimeoutForTest1; - expect(shouldFnBind(o.setTimeoutForTest1)).toBe(true); - o.setTimeoutForTest1 = new Proxy(o.setTimeoutForTest1, { - apply: (target, thisArg, argArray) => { - console.log("proxy call", { target, thisArg, argArray }); - }, - }); - expect(shouldFnBind(o.setTimeoutForTest1)).toBe(true); - // 例2: setTimeoutForTest2: 验证二次拦截 - // @ts-ignore - o.setTimeoutForTest2 = global.setTimeoutForTest2; - expect(shouldFnBind(o.setTimeoutForTest2)).toBe(true); - o.setTimeoutForTest2 = new Proxy(o.setTimeoutForTest2, { - apply: (target, thisArg, argArray) => { - console.log("proxy call", { target, thisArg, argArray }); - }, - }); - expect(shouldFnBind(o.setTimeoutForTest2)).toBe(true); +describe("shouldFnBind", () => { + it("只把 native-like callable 視為需要 receiver binding", () => { + expect(shouldFnBind(Object.prototype.valueOf)).toBe(true); + expect(shouldFnBind(() => undefined)).toBe(false); + expect(shouldFnBind(function userFunction() {})).toBe(false); + expect(shouldFnBind(function Constructor() {})).toBe(false); + expect(shouldFnBind(new Proxy(Object.prototype.valueOf, {}))).toBe(true); + + const userArrow = () => undefined; + const userFunction = function userFunction() {}; + // A Proxy hides the original source and presents a native-like string, so the heuristic binds it. + expect(shouldFnBind(new Proxy(userArrow, {}))).toBe(true); + expect(shouldFnBind(new Proxy(userFunction, {}))).toBe(false); }); }); -describe.concurrent("createContext", () => { - it.concurrent("按 @grant 注入 GM_ 与 GM.* 双命名空间,并忽略未知 grant", async () => { - const context = createTestContext(["GM_getValue", "GM_setValue", "GM.cookie", "not_exist"]); +describe("createContext: capability and lifecycle contract", () => { + it("建立 GM_* / GM.* 對稱命名,並對未知 grant 保持閉合", async () => { + const context = createTestContext(["GM_getValue", "GM_setValue", "GM_cookie", "not_exist"]); expect(context.GM_getValue("foo")).toBe("bar"); expect(await context.GM.getValue("foo")).toBe("bar"); - expect(context.GM_setValue.name).toBe("bound GM_setValue"); - expect(context.GM.setValue.name).toBe("bound GM.setValue"); - expect(context.GM.cookie.name).toBe("bound GM.cookie"); - expect(context.GM.cookie.set.name).toBe("bound GM.cookie.set"); - expect(context.GM.cookie.list.name).toBe("bound GM.cookie.list"); + expect(context.GM_setValue).toBeTypeOf("function"); + expect(context.GM.setValue).toBeTypeOf("function"); + expect(context.GM_cookie).toBeTypeOf("function"); + expect(context.GM_cookie.set).toBeTypeOf("function"); + expect(context.GM_cookie.list).toBeTypeOf("function"); + expect(context.GM_cookie.delete).toBeTypeOf("function"); expect(context.not_exist).toBeUndefined(); expect(context.grantSet.has("not_exist")).toBe(false); + expect(context.grantSet.has("GM_getValue")).toBe(true); + expect(context.grantSet.has("GM.getValue")).toBe(true); }); - it.concurrent("兼容 GM.Cookie 风格的多级命名空间", () => { - const context = createTestContext(["GM_cookie"]); - - expect(context.GM_cookie.name).toBe("bound GM_cookie"); - expect(context.GM_cookie.set.name).toBe("bound GM_cookie.set"); - expect(context.GM_cookie.list.name).toBe("bound GM_cookie.list"); - expect(context.GM_cookie.delete.name).toBe("bound GM_cookie.delete"); + it.each(["GM.cookie", "GM_cookie"] as const)("雙向注入 cookie API:輸入 %s 時兩種公開形狀都可用", (grant) => { + const context = createTestContext([grant]); + + expect(context.GM.cookie).toBeTypeOf("function"); + expect(context.GM.cookie.set).toBeTypeOf("function"); + expect(context.GM.cookie.list).toBeTypeOf("function"); + expect(context.GM.cookie.delete).toBeTypeOf("function"); + expect(context.GM_cookie).toBeTypeOf("function"); + expect(context.GM_cookie.set).toBeTypeOf("function"); + expect(context.GM_cookie.list).toBeTypeOf("function"); + expect(context.GM_cookie.delete).toBeTypeOf("function"); + expect(context.grantSet.has("GM.cookie")).toBe(true); + expect(context.grantSet.has("GM_cookie")).toBe(true); }); - it.concurrent("window grant 先挂到 context.window,再由代理沙盒暴露为 window 方法", () => { + it("將 window grant 留在 context.window,投影時才暴露到 sandbox", () => { const context = createTestContext(["window.close", "window.focus"]); - const sandbox = createProxyContext(context); + const sandbox = createProxyContext(context, createSplitRealmRoots().roots); expect(context.close).toBeUndefined(); - expect(context.window.close.name).toBe("bound window.close"); - expect(context.window.focus.name).toBe("bound window.focus"); + expect(context.window.close).toBeTypeOf("function"); + expect(context.window.focus).toBeTypeOf("function"); expect(sandbox.close).toBe(context.window.close); expect(sandbox.focus).toBe(context.window.focus); }); - it.concurrent("early-start 脚本会等待 loadScriptResolve 后才完成 CAT_scriptLoaded", async () => { + it("early-start 的 CAT_scriptLoaded 只在 resolve 後完成", async () => { const context = createTestContext(["CAT_scriptLoaded"], { "early-start": [""], "run-at": ["document-start"], @@ -126,22 +359,21 @@ describe.concurrent("createContext", () => { await Promise.resolve(); expect(loaded).toBe(false); - - (context as any).loadScriptResolve(); + const loadScriptResolve = (context as unknown as AnyRecord).loadScriptResolve as () => void; + loadScriptResolve(); await loadedPromise; expect(loaded).toBe(true); }); - it.concurrent("非 early-start 脚本的 CAT_scriptLoaded 不会产生等待 Promise", () => { - const context = createTestContext(["CAT_scriptLoaded"], { - "run-at": ["document-end"], - }); + it("非 early-start 不建立多餘的等待點", () => { + const context = createTestContext(["CAT_scriptLoaded"], { "run-at": ["document-end"] }); + const contextValues = context as unknown as AnyRecord; expect(context.CAT_scriptLoaded()).toBeUndefined(); - expect((context as any).loadScriptResolve).toBeUndefined(); + expect(contextValues.loadScriptResolve).toBeUndefined(); }); - it.concurrent("setInvalidContext 会释放监听器且后续 valueUpdate 不再触发", () => { + it("失效清理是 idempotent,且舊 value listener 不再收到更新", () => { const script = createScriptInfo(); const context = createContext( script, @@ -154,110 +386,92 @@ describe.concurrent("createContext", () => { const listener = vi.fn(); context.GM_addValueChangeListener("foo", listener); - context.valueUpdate({ - id: "remote-1", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 7 }, - entries: [["foo", encodeRValue("next"), encodeRValue("bar")]], - valueUpdated: true, - }); + const update = (id: string, value: string, tabId: number) => + context.valueUpdate({ + id, + uuid: script.uuid, + storageName: "", + sender: { runFlag: "other-run-flag", tabId }, + entries: [["foo", encodeRValue(value), encodeRValue("bar")]], + valueUpdated: true, + }); + + update("remote-1", "next", 7); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); + const contextValues = context as unknown as AnyRecord; + const runFlag = contextValues.runFlag; context.setInvalidContext(); context.setInvalidContext(); + expect(context.isInvalidContext()).toBe(true); + expect(contextValues.runFlag).not.toBe(runFlag); + expect(contextValues.runFlag).toContain("(invalid)"); + expect(contextValues.message).toBeNull(); + expect(contextValues.scriptRes).toBeNull(); - context.valueUpdate({ - id: "remote-2", - uuid: script.uuid, - storageName: "", - sender: { runFlag: "other-run-flag", tabId: 8 }, - entries: [["foo", encodeRValue("again"), encodeRValue("next")]], - valueUpdated: true, - }); + update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); }); }); -describe.concurrent("createProxyContext", () => { +describe.sequential("createProxyContext: module default split roots", () => { afterEach(() => { - vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.resetModules(); }); - it.concurrent("隔离沙盒全局对象、保护内部字段,并提供一次性的 $ 入口", () => { - const context = createTestContext(["GM_getValue"]); - const sandbox = createProxyContext(context); - - expect(sandbox.window).toBe(sandbox); - expect(sandbox.self).toBe(sandbox); - expect(sandbox.globalThis).toBe(sandbox); - expect(sandbox.parent).toBe(sandbox); - // DOM 测试环境的 frames 可能返回 Window proxy;这里覆盖浏览器稳定的自引用关键字。 - expect(sandbox.GM_getValue("foo")).toBe("bar"); - expect(sandbox.runFlag).toBeUndefined(); - expect(sandbox.message).toBeUndefined(); - expect(sandbox.define).toBeUndefined(); - expect(sandbox.module).toBeUndefined(); - expect(sandbox.exports).toBeUndefined(); - expect(sandbox.console).not.toBe(console); - - const firstDollarRead = sandbox.$; - expect(firstDollarRead).toBe(sandbox); - expect("$" in sandbox).toBe(false); - }); + it("不傳 roots 時仍從模組載入的 fake window 原型鏈補齊 host method", async () => { + const fixture = createDefaultRootWindow(); + const hostArray = class HostArray {}; + fixture.hostWindow.Array = hostArray; + vi.stubGlobal("defaultRootRealmOnly", "realm-default"); + vi.stubGlobal("window", fixture.hostWindow); + vi.resetModules(); - it.concurrent("原生函数会绑定到真实 global,避免作为裸函数调用时报 Illegal invocation", () => { - const sandbox = createProxyContext(createTestContext([])); - const setTimeoutForTest1 = sandbox.setTimeoutForTest1; + const module = await import("./create_context.js"); + const context = module.createContext( + createScriptInfo(), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set() + ); + const sandbox = module.createProxyContext(context); - expect(() => setTimeoutForTest1(() => undefined, 0)).not.toThrow(); + expect(sandbox.dynamicPrototypeMethod()).toBe(fixture.hostWindow); + expect(sandbox.dynamicPrototypeValue).toBe(fixture.hostWindow.dynamicPrototypeValue); + expect(sandbox.Array).toBe(globalThis.Array); + expect(sandbox.Array).not.toBe(hostArray); + expect(sandbox.defaultRootRealmOnly).toBe("realm-default"); }); - it.concurrent("onxxx 事件属性使用沙盒 this,并在清空后移除页面监听", () => { - const addEventListener = vi.spyOn(global, "addEventListener"); - const removeEventListener = vi.spyOn(global, "removeEventListener"); - const sandbox = createProxyContext(createTestContext([])); - const onload = vi.fn(function (this: any) { - expect(this).toBe(sandbox); - }); - - sandbox.onload = onload; - expect(addEventListener).toHaveBeenCalledWith("load", expect.any(Object)); - - const eventObject = addEventListener.mock.calls.find(([name]) => name === "load")?.[1] as EventListenerObject; - eventObject.handleEvent(new Event("load")); - expect(onload).toHaveBeenCalledTimes(1); - - sandbox.onload = null; - expect(removeEventListener).toHaveBeenCalledWith("load", eventObject); - }); + it("default roots 下 constructor/interface 仍保留 prototype 與 static member", async () => { + const fixture = createDefaultRootWindow(); + vi.stubGlobal("window", fixture.hostWindow); + vi.resetModules(); - it.concurrent("TM半沙盒:把祖先类别继承直接写在半沙盒上 ( #1462 #1463 )", () => { - const sandbox = createProxyContext(createTestContext([])); - expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); - }); -}); + const module = await import("./create_context.js"); + const context = module.createContext( + createScriptInfo(), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set() + ); + const sandbox = module.createProxyContext(context); -// Firefox 的 content / USER_SCRIPT world 全局是 Cu.Sandbox:globalThis 与 window 分属两个 realm, -// 沙盒的原型链在 Xray window 处截断,EventTarget.prototype 上的成员只能经 window 取得。 -// happy-dom 里 globalThis === window,只能用一个「仅存在于 window 原型链上」的成员模拟该拓扑。 -describe("Firefox content world:globalThis 与 window 分属不同 realm", () => { - afterEach(() => { - vi.unstubAllGlobals(); - vi.resetModules(); + expect(sandbox.NodeLike.ELEMENT_NODE).toBe(1); + expect(sandbox.NodeLike.prototype).toBe(fixture.hostWindow.NodeLike.prototype); + expect(sandbox.FilterLike.SHOW_TEXT).toBe(4); + expect(sandbox.XMLHttpRequestLike.DONE).toBe(4); }); - it("沙盒补齐只能经 window 原型链取得的成员 (#1692)", async () => { - const windowProto = Object.create(null); - // 原生 DOM 方法没有 prototype,这里必须用同样形状(方法简写),否则模型不成立 - windowProto.onlyReachableViaWindow = { - onlyReachableViaWindow(this: unknown) { - return this; - }, - }.onlyReachableViaWindow; - const fakeWindow = Object.create(windowProto); - vi.stubGlobal("window", fakeWindow); + it("default roots 下 unsafeWindow 指向 host,window/self/globalThis 仍指向當前 sandbox", async () => { + const fixture = createDefaultRootWindow(); + vi.stubGlobal("window", fixture.hostWindow); vi.resetModules(); const module = await import("./create_context.js"); @@ -271,69 +485,470 @@ describe("Firefox content world:globalThis 与 window 分属不同 realm", () ); const sandbox = module.createProxyContext(context); - expect(typeof sandbox.onlyReachableViaWindow).toBe("function"); - // bind 目标必须跟随该轮的根物件,否则跨 realm 呼叫会触发 brand check 失败 - expect(sandbox.onlyReachableViaWindow()).toBe(fakeWindow); - }); - - it("接口物件保留 prototype 与静态常量,不被 bind 剥空", async () => { - // bind 的产物没有 prototype、也丢掉全部静态成员。Firefox 的 Cu.Sandbox 上 - // Node / NodeFilter 之类不是自有属性,会走到 protoBaseDescs 分支, - // 无差别 bind 会让 Node.ELEMENT_NODE / NodeFilter.SHOW_TEXT 全变成 undefined。 - const windowProto = Object.create(null); - // 构造函数形状(Node、Event、XMLHttpRequest) - const NodeLike = function NodeLike() {}; - (NodeLike as any).ELEMENT_NODE = 1; - windowProto.NodeLike = NodeLike; - // 回调接口形状(NodeFilter):大写字头但没有 prototype - const FilterLike = () => undefined; - (FilterLike as any).SHOW_TEXT = 4; - windowProto.FilterLike = FilterLike; - - const fakeWindow = Object.create(windowProto); - vi.stubGlobal("window", fakeWindow); + expect(context.unsafeWindow).toBe(fixture.hostWindow); + expect(sandbox.unsafeWindow).toBe(fixture.hostWindow); + expect(sandbox).not.toBe(fixture.hostWindow); + expect(sandbox.window).toBe(sandbox); + expect(sandbox.self).toBe(sandbox); + expect(sandbox.globalThis).toBe(sandbox); + }); + + it("default roots 下有 window.onurlchange grant 時可由同一 host navigation channel 收到事件", async () => { + const fixture = createNavigableWindowFixture(); + vi.stubGlobal("window", fixture.hostWindow); vi.resetModules(); const module = await import("./create_context.js"); - const sandbox = module.createProxyContext( - module.createContext( - createScriptInfo(), - { script: { name: "create-context-test" }, scriptMetaStr: "" }, - "vitest", - undefined as any, - undefined as any, - new Set() - ) + const context = module.createContext( + createScriptInfo(), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set(["window.onurlchange"]) ); + const sandbox = module.createProxyContext(context); + const handler = vi.fn(function (this: unknown, event: any) { + expect(this).toBe(sandbox); + expect(event.type).toBe("urlchange"); + expect(typeof event.url).toBe("string"); + }); - expect(sandbox.NodeLike.ELEMENT_NODE).toBe(1); - expect(sandbox.NodeLike.prototype).toBe(NodeLike.prototype); - expect(sandbox.FilterLike.SHOW_TEXT).toBe(4); + expect(context.onurlchange).toBeNull(); + sandbox.onurlchange = handler; + fixture.location.href = "https://example.test/next"; + fixture.navigation.dispatchEvent(new Event("navigate")); + await Promise.resolve(); + + const destinationEvent = new Event("navigate"); + Object.defineProperty(destinationEvent, "destination", { + configurable: true, + value: { url: "https://example.test/destination" }, + }); + fixture.location.href = "https://example.test/final"; + fixture.navigation.dispatchEvent(destinationEvent); + await Promise.resolve(); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler.mock.calls[0][0].url).toBe("https://example.test/next"); + expect(handler.mock.calls[1][0].url).toBe("https://example.test/destination"); + expect(sandbox.onurlchange).toBe(handler); }); +}); - it("window / self 指向沙盒自身,不逃逸到页面 window", async () => { - // Firefox 下 globalThis.window 是页面 Window 的 Xray 包装,不等于 global; - // 只按 global 判定自引用会让沙盒里的 window / self 指回页面, - // 脚本写在 self 上的东西(例如沉浸式翻译的 GM_fetch)就落到了页面而不是沙盒。 - const pageWindow: Record = Object.create(null); - pageWindow.window = pageWindow; - pageWindow.self = pageWindow; - vi.stubGlobal("window", pageWindow); - vi.resetModules(); +describe("createProxyContext: deterministic realm contract", () => { + it("固定 window/self/globalThis,並把每次 sandbox 的寫入隔離", () => { + const first = createProxyFixture({ GM_getValue: vi.fn() }); + const second = createProxyFixture({ GM_getValue: vi.fn() }); + + expect(first.sandbox.window).toBe(first.sandbox); + expect(first.sandbox.self).toBe(first.sandbox); + expect(first.sandbox.globalThis).toBe(first.sandbox); + expect(first.sandbox).not.toBe(first.hostWindow); + expect(Object.getPrototypeOf(first.sandbox)).toBeNull(); + expect(first.sandbox.define).toBeUndefined(); + expect(first.sandbox.module).toBeUndefined(); + expect(first.sandbox.exports).toBeUndefined(); + expect(first.sandbox.console).not.toBe(console); + + const dollar = first.sandbox.$; + expect(dollar).toBe(first.sandbox); + expect("$" in first.sandbox).toBe(false); + expect(first.sandbox.$).toBeUndefined(); + + first.sandbox.__local = "first"; + expect(first.sandbox.__local).toBe("first"); + expect(second.sandbox.__local).toBeUndefined(); + expect(first.hostWindow.__local).toBeUndefined(); + expect(first.sandbox.GM_getValue).toBe(first.context.GM_getValue); + }); - const module = await import("./create_context.js"); - const sandbox = module.createProxyContext( - module.createContext( - createScriptInfo(), - { script: { name: "create-context-test" }, scriptMetaStr: "" }, - "vitest", - undefined as any, - undefined as any, - new Set() - ) - ); + it("realmGlobal own descriptor 優先於 hostWindow 同名 descriptor", () => { + const fixture = createSplitRealmRoots(); + const realmMath = { max: () => "realm" }; + const hostMath = { max: () => "host" }; + fixture.realmGlobal.Math = realmMath; + fixture.hostWindow.Math = hostMath; + fixture.realmGlobal.realmNativeCallable = Object.prototype.valueOf; + fixture.hostWindowPrototype.realmNativeCallable = Object.prototype.toString; + fixture.realmGlobal.realmDataVsHostAccessor = "realm-data"; + Object.defineProperty(fixture.hostWindowPrototype, "realmDataVsHostAccessor", { + configurable: true, + enumerable: true, + get: () => "host-accessor", + set: () => undefined, + }); + fixture.realmGlobal.realmDataVsHostNative = "realm-data"; + fixture.hostWindowPrototype.realmDataVsHostNative = Object.prototype.valueOf; + Object.defineProperty(fixture.realmGlobal, "sameAccessor", { + configurable: true, + enumerable: true, + get() { + return this === fixture.realmGlobal ? "realm" : "wrong-receiver"; + }, + set(value: string) { + fixture.realmGlobal.sameAccessorValue = this === fixture.realmGlobal ? value : "wrong-receiver"; + }, + }); + Object.defineProperty(fixture.hostWindowPrototype, "sameAccessor", { + configurable: true, + enumerable: true, + get() { + return this === fixture.hostWindow ? "host" : "wrong-receiver"; + }, + set() { + throw new Error("host accessor should not win over realm own accessor"); + }, + }); + + const sandbox = createProxyContext(Object.create(null), fixture.roots); + expect(sandbox.Math).toBe(realmMath); + expect(sandbox.Math).not.toBe(hostMath); + expect(sandbox.realmOnly).toBe("realm-value"); + expect(sandbox.realmAccessor).toBe("realm-receiver"); + expect(sandbox.realmNativeCallable()).toBe(fixture.realmGlobal); + expect(sandbox.sameAccessor).toBe("realm"); + expect(sandbox.realmDataVsHostAccessor).toBe("realm-data"); + expect(sandbox.realmDataVsHostNative).toBe("realm-data"); + }); + + it("Chrome shared-root 下仍保留 host prototype、內建 static 與 alias 語義", () => { + const fixture = createSharedRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(sandbox.Math).toBe(fixture.hostWindow.Math); + expect(sandbox.Math.max(2, 7)).toBe(7); + expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); + expect(sandbox.document).toBe(fixture.hostWindow.document); + expect(sandbox.dynamicPrototypeMethod()).toBe(fixture.hostWindow); + expect(sandbox.dynamicHostAccessor).toBe("host-value"); + + sandbox.dynamicHostAccessor = "updated"; + expect(sandbox.dynamicHostAccessor).toBe("updated"); expect(sandbox.window).toBe(sandbox); expect(sandbox.self).toBe(sandbox); + expect(sandbox.globalThis).toBe(sandbox); + }); + + it("收集 host prototype 與明確保留的 document own 成員,並綁定正確 receiver", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(sandbox.dynamicHostValue).toBe(fixture.hostWindow.dynamicHostValue); + expect(sandbox.dynamicHostMethod()).toBe(fixture.hostWindow); + expect(sandbox.dynamicPrototypeMethod()).toBe(fixture.hostWindow); + expect(sandbox.hostNativeCallable()).toBe(fixture.hostWindow); + expect(sandbox.dynamicPrototypeValue).toBe(fixture.hostWindow.dynamicPrototypeValue); + expect(sandbox.dynamicHostAccessor).toBe("host-value"); + expect(sandbox.document).toBe(fixture.hostWindow.document); + + sandbox.dynamicHostAccessor = "updated"; + expect(sandbox.dynamicHostAccessor).toBe("updated"); + + expect(Object.getOwnPropertyDescriptor(sandbox, "dynamicPrototypeValue")).toMatchObject({ + value: fixture.hostWindow.dynamicPrototypeValue, + writable: false, + enumerable: true, + configurable: true, + }); + }); + + it("host own page property 會隨 host descriptor snapshot 複製到 sandbox", () => { + const fixture = createSplitRealmRoots(); + fixture.hostWindow.hostOnlySecret = { source: "page-only" }; + Object.defineProperty(fixture.hostWindow, "hostOnlyHiddenSecret", { + configurable: true, + enumerable: false, + value: { source: "page-only-hidden" }, + writable: true, + }); + + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(sandbox.hostOnlySecret).toBe(fixture.hostWindow.hostOnlySecret); + expect(sandbox.hostOnlyHiddenSecret).toBe(fixture.hostWindow.hostOnlyHiddenSecret); + }); + + it("保留 constructor/interface 的 prototype 與 static member,不把它們 bind 剝空", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(sandbox.NodeLike.ELEMENT_NODE).toBe(1); + expect(sandbox.NodeLike.prototype).toBe(fixture.hostWindow.NodeLike.prototype); + expect(sandbox.FilterLike.SHOW_TEXT).toBe(4); + expect(sandbox.FilterLike).toBe(fixture.hostWindow.FilterLike); + expect(sandbox.XMLHttpRequestLike.DONE).toBe(4); + expect(sandbox.XMLHttpRequestLike.prototype).toBe(fixture.hostWindow.XMLHttpRequestLike.prototype); + }); + + it("以 hostWindow identity 建立 pseudo-window 的 toStringTag/constructor/__proto__", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(Object.prototype.toString.call(sandbox)).toBe("[object Window]"); + expect(sandbox.constructor).toBe(fixture.hostWindow.constructor); + expect(sandbox.__proto__).toBe(fixture.hostWindow.__proto__); + expect(Object.getPrototypeOf(sandbox)).toBeNull(); + }); + + it("抽出 host EventTarget 方法後仍可呼叫,且 listener 只觸發一次", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const eventName = "extracted-host-event"; + const listener = vi.fn(); + const add = sandbox.addEventListener; + const remove = sandbox.removeEventListener; + const dispatch = sandbox.dispatchEvent as unknown as (event: { type: string }) => boolean; + + add(eventName, listener); + dispatch(new fixture.TestEvent(eventName)); + remove(eventName, listener); + dispatch(new fixture.TestEvent(eventName)); + + expect(listener).toHaveBeenCalledTimes(1); + expect(fixture.eventTarget.listenerCount(eventName)).toBe(0); + }); + + it("僅存在於 host prototype 的 onmessage descriptor 也會建立 sandbox event channel", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + + sandbox.onmessage = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("message")); + sandbox.onmessage = null; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("message")); + + expect(handler).toHaveBeenCalledTimes(1); + expect(fixture.eventTarget.listenerCount("message")).toBe(0); + }); + + it("host prototype accessor 以最近 descriptor 為準,不被 parent descriptor 覆寫", () => { + const fixture = createSplitRealmRoots(); + const parentPrototype = Object.create(null) as AnyRecord; + const hostPrototype = Object.create(parentPrototype) as AnyRecord; + let hostValue = "unset"; + + Object.defineProperty(parentPrototype, "precedenceAccessor", { + configurable: true, + enumerable: true, + get: () => "parent", + set: () => undefined, + }); + Object.defineProperty(hostPrototype, "precedenceAccessor", { + configurable: true, + enumerable: true, + get() { + return this === fixture.hostWindow ? "host" : "wrong-receiver"; + }, + set(value: string) { + hostValue = this === fixture.hostWindow ? value : "wrong-receiver"; + }, + }); + Object.defineProperty(hostPrototype, "hostAccessor", { + configurable: true, + enumerable: true, + get() { + return this === fixture.hostWindow ? "host" : "wrong-receiver"; + }, + set(value: string) { + hostValue = this === fixture.hostWindow ? value : "wrong-receiver"; + }, + }); + Object.setPrototypeOf(parentPrototype, fixture.hostWindowPrototype); + Object.setPrototypeOf(fixture.hostWindow, hostPrototype); + + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + expect(sandbox.precedenceAccessor).toBe("host"); + expect(sandbox.hostAccessor).toBe("host"); + sandbox.hostAccessor = "updated"; + expect(hostValue).toBe("updated"); + }); + + it("accessor materialization 不應依賴 host getter/setter 自身的 .bind,且保留 descriptor flags", () => { + const fixture = createSplitRealmRoots(); + let receivedValue = "unset"; + const getter = function (this: unknown) { + return this === fixture.hostWindow ? "host" : "wrong-receiver"; + }; + const setter = function (this: unknown, value: string) { + receivedValue = this === fixture.hostWindow ? value : "wrong-receiver"; + }; + + // 模擬 Xray callable:函數本身存在,但不保證透過 property lookup 取得 .bind。 + Object.defineProperty(getter, "bind", { configurable: true, value: undefined }); + Object.defineProperty(setter, "bind", { configurable: true, value: undefined }); + Object.defineProperty(fixture.hostWindowPrototype, "xrayLikeAccessor", { + configurable: true, + enumerable: true, + get: getter, + set: setter, + }); + + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const descriptor = Object.getOwnPropertyDescriptor(sandbox, "xrayLikeAccessor"); + + expect(sandbox.xrayLikeAccessor).toBe("host"); + sandbox.xrayLikeAccessor = "updated"; + expect(receivedValue).toBe("updated"); + expect(descriptor).toMatchObject({ configurable: true, enumerable: true }); + expect(descriptor?.get).toBeTypeOf("function"); + expect(descriptor?.set).toBeTypeOf("function"); + }); + + it("同名 realm accessor 不會阻止 host on* event property 被收集", () => { + const fixture = createSplitRealmRoots(); + Object.defineProperty(fixture.realmGlobal, "oncollectorcollision", { + configurable: false, + enumerable: true, + get: () => "realm-accessor", + }); + Object.defineProperty(fixture.hostWindow, "oncollectorcollision", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(); + + sandbox.oncollectorcollision = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("collectorcollision")); + sandbox.oncollectorcollision = null; + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("on* 狀態機:function replacement 不重複註冊,object/null 會移除 callback", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const first = vi.fn(); + const second = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + const objectHandler = { handleEvent: vi.fn() }; + + sandbox.onload = first; + sandbox.onload = second; + expect(fixture.eventTarget.listenerCount("load")).toBe(1); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + + Reflect.set(sandbox, "onload", objectHandler); + expect(fixture.eventTarget.listenerCount("load")).toBe(0); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(objectHandler.handleEvent).not.toHaveBeenCalled(); + + const third = vi.fn(); + sandbox.onload = third; + expect(fixture.eventTarget.listenerCount("load")).toBe(1); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(third).toHaveBeenCalledTimes(1); + + Reflect.set(sandbox, "onload", 0); + expect(sandbox.onload).toBeNull(); + expect(fixture.eventTarget.listenerCount("load")).toBe(0); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(third).toHaveBeenCalledTimes(1); + + Reflect.set(sandbox, "onload", "not-a-handler"); + expect(sandbox.onload).toBeNull(); + expect(fixture.eventTarget.listenerCount("load")).toBe(0); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + expect(third).toHaveBeenCalledTimes(1); + }); + + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + + Reflect.set(sandbox.self, "__split_alias_value", "sandbox-value"); + + expect(Reflect.get(sandbox.window, "__split_alias_value")).toBe("sandbox-value"); + expect(Reflect.get(sandbox.globalThis, "__split_alias_value")).toBe("sandbox-value"); + expect(Reflect.get(fixture.hostWindow, "__split_alias_value")).toBeUndefined(); + }); + + it("top/parent/frames 在自身引用與 iframe 非自身引用間保持語義", () => { + const fixture = createSplitRealmRoots(); + const parentWindow = Object.create(null); + const topWindow = Object.create(null); + const frames = Object.create(null); + fixture.hostWindow.parent = parentWindow; + fixture.hostWindow.top = topWindow; + fixture.hostWindow.frames = frames; + + const iframeSandbox = createProxyContext(Object.create(null), fixture.roots); + expect(iframeSandbox.parent).toBe(parentWindow); + expect(iframeSandbox.top).toBe(topWindow); + expect(iframeSandbox.frames).toBe(frames); + + fixture.hostWindow.parent = fixture.hostWindow; + fixture.hostWindow.top = fixture.hostWindow; + fixture.hostWindow.frames = fixture.hostWindow; + const topSandbox = createProxyContext(Object.create(null), fixture.roots); + expect(topSandbox.parent).toBe(topSandbox); + expect(topSandbox.top).toBe(topSandbox); + expect(topSandbox.frames).toBe(topSandbox); + }); + + it("context protect 欄位不會複製到 script global,但已授權 API 會複製", () => { + const api = vi.fn(); + const fixture = createProxyFixture({ + GM_getValue: api, + runFlag: "internal-run-flag", + message: { secret: true }, + contentMsg: { secret: true }, + grantSet: new Set(["GM_getValue"]), + EE: { secret: true }, + }); + + expect(fixture.sandbox.GM_getValue).toBe(api); + expect(fixture.sandbox.runFlag).toBeUndefined(); + expect(fixture.sandbox.message).toBeUndefined(); + expect(fixture.sandbox.contentMsg).toBeUndefined(); + expect(fixture.sandbox.grantSet).toBeUndefined(); + expect(fixture.sandbox.EE).toBeUndefined(); + }); + + it("沒有 window.onurlchange grant 時不建立 accessor 或 host navigation channel", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(createTestContext([]), fixture.roots); + const handler = vi.fn(); + + expect(sandbox.onurlchange).toBeUndefined(); + expect("onurlchange" in sandbox).toBe(false); + + sandbox.onurlchange = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("urlchange")); + + expect(handler).not.toHaveBeenCalled(); + expect(fixture.eventTarget.listenerCount("urlchange")).toBe(0); + }); + + it("@grant window.onurlchange 使用獨立 accessor 與 host urlchange channel", () => { + const fixture = createSplitRealmRoots(); + const context = createTestContext(["window.onurlchange"]); + + expect(context.onurlchange).toBeNull(); + const sandbox = createProxyContext(context, fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + + sandbox.onurlchange = handler; + expect(sandbox.onurlchange).toBe(handler); + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("urlchange")); + expect(handler).toHaveBeenCalledTimes(1); + + sandbox.onurlchange = null; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("urlchange")); + expect(handler).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 57cd30365..0c7174cad 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -2,8 +2,7 @@ import type { TScriptInfo } from "@App/app/repo/scripts"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { Message } from "@Packages/message/types"; import EventEmitter from "eventemitter3"; -import { GMContextApiGet } from "./gm_api/gm_context"; -import { protect } from "./gm_api/gm_context"; +import { GMContextApiGet, protect } from "./gm_api/gm_context"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; @@ -104,6 +103,10 @@ export const createContext = ( g = g[part] || (g[part] = grantedAPIs[s] || Object.create(null)); } } + if (!Object.hasOwn(window, "globalThis")) { + //@ts-ignore + window["globalThis"] = window; + } context.unsafeWindow = window; if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { context.onurlchange = null; @@ -155,155 +158,195 @@ export const shouldFnBind = (f: any) => { return false; }; -// 判断是否为「需要 this 的方法」。沿用 shouldFnBind 的结构判定(有 prototype 即为 Class; -// 小写字头才是可直接呼叫的方法,大写字头是 Node / NodeFilter 之类接口物件),但不做原生代码 -// toString 测试 —— 被扩展 Proxy 封装过的方法同样需要 bind。 -const isBindableMethod = (f: any) => { - if (typeof f !== "function") return false; - if ("prototype" in f) return false; - const { name } = f as typeof Function.prototype; - if (!name) return false; - const e = name.charCodeAt(0); - return e >= 97 && e <= 122 && !name.includes(" "); -}; +// 取物件本身及所有父类(不包含Object)的PropertyDescriptor +type DescriptorOwner = Record; -type ForEachCallback = (value: T, index: number, array: T[]) => void; +type DescriptorMap = Record; -// 取物件本身及所有父类(不包含Object)的PropertyDescriptor -const getAllPropertyDescriptors = (obj: any, callback: ForEachCallback<[string | symbol, PropertyDescriptor]>) => { +const getAllPropertyDescriptors = ( + obj: DescriptorOwner, + callback: (key: string | symbol, descriptor: PropertyDescriptor) => void +) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); - Object.entries(descs).forEach(callback); + for (const key of Reflect.ownKeys(descs)) { + callback(key, descs[key as keyof typeof descs]); + } obj = Object.getPrototypeOf(obj); } }; -// 在 CacheSet 加入的propKeys将会在 mySandbox 实装阶段时设置 -const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); - -const initOwnDescs = Object.getOwnPropertyDescriptors(global); - -// overridedDescs将以物件OwnPropertyDescriptor方式进行物件属性修改 -// 覆盖原有的 OwnPropertyDescriptor定义 或 父类的PropertyDescriptor定义 -const overridedDescs: Record = Object.create(null); - -// 记录原生 onxxxxx 的 PropertyDescriptor -const eventDescs: Record = Object.create(null); - -// 在 USE_PSEUDO_WINDOW 情况下,由于没有 类的prototype, 父类的成员要手动传下去 -const protoBaseDescs: Record = Object.create(null); - -// 包含物件本身及所有父类(不包含Object)的PropertyDescriptor -// 主要是找出哪些 function值, setter/getter 需要替换 global window -// bind 目标跟随该轮的根物件 root,因为两个根分属不同 realm,互相绑定会触发 brand check 失败 -const collectPropertyDescriptors = (root: any) => - getAllPropertyDescriptors(root, ([key, desc]) => { - if (!desc || descsCache.has(key) || typeof key !== "string") return; - - if (desc.writable) { - // 属性 value - - const value = desc.value; - - // 替换 function 的 this 为 实际的 global window - // 例:父类的 addEventListener - // 对于构造函数和类(有 prototype 属性),shouldFnBind 会返回 false,跳过绑定 - // 因此被封装的属性,会略过封装层,继续向父类寻找原生属性 - if (shouldFnBind(value)) { - const boundValue = value.bind(root); - overridedDescs[key] = { - ...desc, - value: boundValue, - }; - descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(root, key)) { - if (!protoBaseDescs[key]) { - // 只有「需要 this 的方法」才 bind。接口物件(Node、NodeFilter、Event、XMLHttpRequest…) - // bind 之后会丢掉 prototype 和全部静态成员,Node.ELEMENT_NODE / NodeFilter.SHOW_TEXT - // 之类的常量全部变成 undefined,DOM 遍历会静默失效。 - // Chrome 下 global 就是 window,这些键都在 initOwnDescs 里、走不到这一支; - // Firefox 的 Cu.Sandbox 没有这些自有属性,不加判断就会把接口物件剥成 length/name。 - if (isBindableMethod(value)) { - protoBaseDescs[key] = { - ...desc, - value: value.bind(root), - }; - } else { - protoBaseDescs[key] = { ...desc }; - } +// constructor/interface 不可绑定,否则 bind 会丢失 prototype 和静态成员。 +const isConstructorOrInterface = (value: unknown) => { + if (typeof value !== "function") return false; + if ("prototype" in value) return true; + const firstChar = (value as { name: string }).name.charCodeAt(0); + return firstChar >= 65 && firstChar <= 90; +}; + +// 避免 host/Xray function 的 .bind lookup 不可靠 +const bindFn = Function.prototype.bind; + +const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { + if ("value" in descriptor) { + if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; + return { + ...descriptor, + value: bindFn.call(descriptor.value, receiver), + }; + } + if (!descriptor.get && !descriptor.set) return descriptor; + return { + ...descriptor, + get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, + set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, + }; +}; + +type GlobalSnapshot = { + sharedInitCopy: typeof globalThis & Record; + eventKeys: Set; +}; + +export type RealmRoots = { + realmGlobal: DescriptorOwner; + hostWindow: DescriptorOwner; +}; + +const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { + // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 + // 先处理的 descriptor 覆盖后续父类。 + const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + + // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 + const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + + // overriddenDescs 将以物件 OwnPropertyDescriptor 方式进行物件属性修改。 + // 覆盖原有的 OwnPropertyDescriptor 定义或父类的 PropertyDescriptor 定义。 + const overriddenDescs: DescriptorMap = Object.create(null); + + // 记录原生 onxxxxx 的 property key。 + const eventKeys = new Set(); + + // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 + const protoBaseDescs: DescriptorMap = Object.create(null); + + const collectRealmDescriptors = () => { + // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 + const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); + for (const key of Object.keys(descriptors)) { + const desc = descriptors[key]; + if (descsCache.has(key)) continue; + descsCache.add(key); // realm own descriptors take precedence over host descriptors + + if ("value" in desc) { + // 替换 function 的 this 为实际的 realm global。 + if (desc.writable && shouldFnBind(desc.value)) { + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); } + continue; } - } else { + if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { - // 替换 onxxxxx 事件赋值操作 - // 例:(window.)onload, (window.)onerror - eventDescs[key] = desc; - } else { - if (desc.get || desc.set) { - // 替换 getter setter 的 this 为 实际的 global window - // 例:(window.)location, (window.)document - overridedDescs[key] = { - ...desc, - get: desc?.get?.bind(root), - set: desc?.set?.bind(root), - }; - descsCache.add(key); // 必须:子类属性覆盖父类属性 - } + // 替换 onxxxxx 事件赋值操作。 + // 例:(window.)onload, (window.)onerror。 + eventKeys.add(key); + continue; + } + if (desc.get || desc.set) { + // 替换 getter setter 的 this 为实际的 realm global。 + // 例:(window.)location, (window.)document。 + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); } } - }); + }; -// 第一趟 globalThis:Firefox 的 content / USER_SCRIPT world 是独立 realm,JS 内置物件只有在这里 -// 才完整;经 Xray 看页面 window 的内置物件会被剥到只剩 length / name / prototype(Number.isNaN、 -// Math 的全部静态成员都会消失)。 -collectPropertyDescriptors(global); -// 第二趟 window:同一个 sandbox 的原型链在 Xray window 处截断,够不到 EventTarget.prototype, -// addEventListener / removeEventListener / dispatchEvent 只能由真实 window 的原型链补齐。 -// descsCache 先到先得,第一趟收下的键不会被覆盖;Chrome 下 window === globalThis,此趟全部跳过。 -window !== global && collectPropertyDescriptors(window); -descsCache.clear(); // 内存释放 - -// sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor -// OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) -// + 覆盖定义 (document, location, setTimeout, setInterval, addEventListener 等) -// sharedInitCopy: ScriptCat脚本共通使用 - -const USE_PSEUDO_WINDOW = true; // 日后或能设置使 ScriptCat的沙盒 window 能以 name / id 存取页面元素 - -class PseudoWindow {} -const PseudoWindowPrototype = PseudoWindow.prototype; -Object.defineProperty(PseudoWindowPrototype, Symbol.toStringTag, { - //@ts-ignore - value: global[Symbol.toStringTag], - writable: false, - enumerable: false, - configurable: true, -}); -Object.defineProperty(PseudoWindowPrototype, "constructor", { - value: global.constructor, - writable: false, - enumerable: false, - configurable: true, -}); -Object.defineProperty(PseudoWindowPrototype, "__proto__", { - //@ts-ignore - value: global.__proto__, - writable: false, - enumerable: false, - configurable: true, -}); - -const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { - ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), - ...initOwnDescs, - ...overridedDescs, - }) - : Object.create(Object.getPrototypeOf(global), { - ...initOwnDescs, - ...overridedDescs, + const collectHostWindowDescriptors = () => { + // 取物件本身及所有父类(不包含Object)的PropertyDescriptor。 + // 主要是找出哪些 function 值、setter/getter 需要替换 host window。 + getAllPropertyDescriptors(hostWindow, (key, desc) => { + if (!desc || typeof key !== "string") return; + + if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { + // 替换 onxxxxx 事件赋值操作。 + // 例:(window.)onload, (window.)onerror。 + eventKeys.add(key); + return; + } + if (descsCache.has(key)) return; + + if ("value" in desc) { + // 替换 function 的 this 为实际的 host window。 + if (shouldFnBind(desc.value)) { + overriddenDescs[key] = materializeDescriptor(desc, hostWindow); + descsCache.add(key); + } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); + } + return; + } + if (desc.get || desc.set) { + // 替换 getter setter 的 this 为实际的 host window。 + // 例:(window.)location, (window.)document。 + overriddenDescs[key] = materializeDescriptor(desc, hostWindow); + descsCache.add(key); + } }); + }; + + // 第一趟 realmGlobal:保留 JavaScript 内置对象。 + collectRealmDescriptors(); + // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 + collectHostWindowDescriptors(); + descsCache.clear(); // 内存释放 + + // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor + // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) + // + 覆盖定义 (document, location, setTimeout, setInterval, addEventListener 等) + // sharedInitCopy: ScriptCat脚本共通使用 + + // PseudoWindow 没有真实 Window.prototype,因此祖先成员必须先手动复制到 sandbox own descriptors。 + const USE_PSEUDO_WINDOW = true; // 日后或能设置使 ScriptCat的沙盒 window 能以 name / id 存取页面元素 + + class PseudoWindow {} + const PseudoWindowPrototype = PseudoWindow.prototype; + Object.defineProperty(PseudoWindowPrototype, Symbol.toStringTag, { + //@ts-ignore + value: hostWindow[Symbol.toStringTag], + writable: false, + enumerable: false, + configurable: true, + }); + Object.defineProperty(PseudoWindowPrototype, "constructor", { + value: hostWindow.constructor, + writable: false, + enumerable: false, + configurable: true, + }); + Object.defineProperty(PseudoWindowPrototype, "__proto__", { + //@ts-ignore + value: hostWindow.__proto__, + writable: false, + enumerable: false, + configurable: true, + }); + + const sharedInitCopy = USE_PSEUDO_WINDOW + ? Object.create(null, { + ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype + ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), + ...initOwnDescs, + ...overriddenDescs, + }) + : Object.create(Object.getPrototypeOf(realmGlobal), { + ...initOwnDescs, + ...overriddenDescs, + }); + + return { sharedInitCopy, eventKeys }; +}; + +const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 const initConsoleDescs = Object.getOwnPropertyDescriptors(console); @@ -313,29 +356,22 @@ type GMWorldContext = typeof globalThis & Record; const isPrimitive = (x: any) => x !== Object(x); -// 判断某个值是否为「本 realm 的 window」,即沙盒自引用应当改写成 mySandbox 的目标。 -// Chrome 的 USER_SCRIPT world 里 global 本身就是 Window(window === global),只有一个候选; -// Firefox 的 content world 里 global 是 Cu.Sandbox、window 是页面 Window 的 Xray 包装, -// 两者都要算,否则 window / self / top / parent 会指回页面而不是沙盒。 -const isRealmWindow = (o: any) => o === global || o === window; - // 拦截上下文 -export const createProxyContext = (context: any): Context => { +export const createProxyContext = ( + context: any, + roots: RealmRoots = { realmGlobal: global, hostWindow: window } +): Context => { // let withContext: Context | undefined | { [key: string]: any } = undefined; // 为避免做成混乱。 ScriptCat脚本中 self, globalThis, parent 为固定值不能修改 + const { sharedInitCopy, eventKeys } = + roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; - - const createFuncWrapper = (f: () => any) => { - return function (this: any) { - const ret = f.call(global); - if (isRealmWindow(ret)) return mySandbox; - return ret; - }; - }; + const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); + const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -345,9 +381,9 @@ export const createProxyContext = (context const eventObject: EventListenerObject & { fn: any } = { fn: null, handleEvent(event) { - const fn = mySandbox[key]; + const fn = mySandbox![key]; if (!fn || fn !== this.fn) { - global.removeEventListener(eventName, eventObject); + hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { fn.call(mySandbox, event); @@ -369,11 +405,11 @@ export const createProxyContext = (context // function <-> function 时无需重新监听 if (typeof fn === "function") { // 停止当前事件监听 - global.removeEventListener(eventName, eventObject); + hostRemoveEventListener(eventName, eventObject); } else if (typeof newVal === "function") { // 非primitive types 的话,只考虑 function type // Symbol, Object (包括 EventListenerObject ) 等只会保存而不进行事件监听 - global.addEventListener(eventName, eventObject); + hostAddEventListener(eventName, eventObject); } } eventObject.fn = newVal; @@ -382,7 +418,7 @@ export const createProxyContext = (context }; }; - for (const key of Object.keys(eventDescs)) { + for (const key of eventKeys) { const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], @@ -390,28 +426,33 @@ export const createProxyContext = (context }; } - for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) { - // Firefox 的 content world 里 window / self / top / parent 都不是 Cu.Sandbox 的自有属性, - // 结构反射也拿不到(Sandbox 的原型是一个原型为 null 的 Window 包装),沙盒因此完全没有这些键, - // with(this.$) 会穿透到外层直接解析到页面 window。按真实取值补出描述符,交给下面的自引用改写。 - const desc = (ownDescs[key] ??= { value: (window)[key], enumerable: true, configurable: true }); - if (isRealmWindow(desc.value)) { - // globalThis - // 避免 self referencing, 改以 getter 形式 - desc.get = function () { + // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 + for (const key of ["window", "self", "globalThis"]) { + ownDescs[key] = { + configurable: true, + enumerable: true, + get() { return mySandbox; - }; - desc.set = undefined; - // 为了 value 转 getter/setter,必须删除 writable 和 value - delete desc.writable; - delete desc.value; - } else if (desc.get) { - // 真实的 window 物件中部份属性(self, parent) 存在setter. 意义不明 - // 为避免做成混乱,ScriptCat脚本的沙盒不提供setter(即不能修改) - // (像window.document, 能写 window.document = null 不会报错但赋值不变) - desc.get = createFuncWrapper(desc.get); - desc.set = undefined; - } + }, + }; + } + for (const key of ["top", "parent", "frames"]) { + const descriptor = ownDescs[key]; + const hostValue = Reflect.get(roots.hostWindow, key, roots.hostWindow); + if (hostValue === undefined && !descriptor) continue; + + ownDescs[key] = { + ...descriptor, + configurable: true, + enumerable: descriptor?.enumerable ?? true, + get() { + const value = Reflect.get(roots.hostWindow, key, roots.hostWindow); + return value === roots.hostWindow || value === roots.realmGlobal ? mySandbox : value; + }, + set: undefined, + }; + delete ownDescs[key].value; + delete ownDescs[key].writable; } if (noEval) { if (ownDescs?.eval?.value) { @@ -447,7 +488,8 @@ export const createProxyContext = (context } // 把初始Copy加上特殊变量后,生成一份新Copy - mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs); + mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & + Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 for (const key of ["define", "module", "exports"]) { @@ -482,7 +524,7 @@ export const createProxyContext = (context const handle = function (this: Window & Record, e: UrlChangeEvent) { this.onurlchange?.(e); } as EventListener; - (window).addEventListener("urlchange", handle.bind(mySandbox), false); + (roots.hostWindow).addEventListener("urlchange", handle.bind(mySandbox), false); } // 从网页 console 隔离出来的沙盒 console