diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..1966ddf08 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -238,3 +238,102 @@ describe.concurrent("createProxyContext", () => { expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); }); }); + +// 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(); + }); + + 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); + vi.resetModules(); + + 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(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); + 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() + ) + ); + + expect(sandbox.NodeLike.ELEMENT_NODE).toBe(1); + expect(sandbox.NodeLike.prototype).toBe(NodeLike.prototype); + expect(sandbox.FilterLike.SHOW_TEXT).toBe(4); + }); + + 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(); + + 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() + ) + ); + + expect(sandbox.window).toBe(sandbox); + expect(sandbox.self).toBe(sandbox); + }); +}); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 165e6819e..57cd30365 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -155,6 +155,18 @@ 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(" "); +}; + type ForEachCallback = (value: T, index: number, array: T[]) => void; // 取物件本身及所有父类(不包含Object)的PropertyDescriptor @@ -206,11 +218,15 @@ const collectPropertyDescriptors = (root: any) => descsCache.add(key); // 必须:子类属性覆盖父类属性 } else if (!(key in initOwnDescs) && !Object.hasOwn(root, key)) { if (!protoBaseDescs[key]) { - if (typeof value === "function") { - const boundValue = value.bind(root); + // 只有「需要 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: boundValue, + value: value.bind(root), }; } else { protoBaseDescs[key] = { ...desc }; @@ -297,6 +313,12 @@ 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 => { // let withContext: Context | undefined | { [key: string]: any } = undefined; @@ -310,7 +332,7 @@ export const createProxyContext = (context const createFuncWrapper = (f: () => any) => { return function (this: any) { const ret = f.call(global); - if (ret === global) return mySandbox; + if (isRealmWindow(ret)) return mySandbox; return ret; }; }; @@ -369,8 +391,11 @@ export const createProxyContext = (context } for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) { - const desc = ownDescs[key]; - if (desc?.value === global) { + // 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 () { @@ -380,7 +405,7 @@ export const createProxyContext = (context // 为了 value 转 getter/setter,必须删除 writable 和 value delete desc.writable; delete desc.value; - } else if (desc?.get) { + } else if (desc.get) { // 真实的 window 物件中部份属性(self, parent) 存在setter. 意义不明 // 为避免做成混乱,ScriptCat脚本的沙盒不提供setter(即不能修改) // (像window.document, 能写 window.document = null 不会报错但赋值不变)