From 42d921a6d8585be52f97366a2cf624a8c225494a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 29 Aug 2026 13:12:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20Firefox=20=E4=B8=8B=20@inject-into=20content=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=B2=99=E7=9B=92=E7=BC=BA=E5=A4=B1=20EventTarget=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=20(#1692)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e57a73404877b9ff4398b55383d2620daaf94614. --- src/app/service/content/create_context.ts | 101 ++++++++++------------ 1 file changed, 45 insertions(+), 56 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 165e6819e..539fbd225 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -183,68 +183,57 @@ 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); +getAllPropertyDescriptors(global, ([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(global); + overridedDescs[key] = { + ...desc, + value: boundValue, + }; + descsCache.add(key); // 必须:子类属性覆盖父类属性 + } else if (!(key in initOwnDescs) && !Object.hasOwn(global, key)) { + if (!protoBaseDescs[key]) { + if (typeof value === "function") { + const boundValue = value.bind(global); + protoBaseDescs[key] = { + ...desc, + value: boundValue, + }; + } else { + protoBaseDescs[key] = { ...desc }; + } + } + } + } 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, - value: boundValue, + get: desc?.get?.bind(global), + set: desc?.set?.bind(global), }; descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(root, key)) { - if (!protoBaseDescs[key]) { - if (typeof value === "function") { - const boundValue = value.bind(root); - protoBaseDescs[key] = { - ...desc, - value: boundValue, - }; - } else { - protoBaseDescs[key] = { ...desc }; - } - } - } - } 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); // 必须:子类属性覆盖父类属性 - } } } - }); - -// 第一趟 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 From 80ebd8cd0b13954523078935f1cbea6a3e4e3d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 29 Aug 2026 19:49:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20Firefox?= =?UTF-8?q?=20content=20world=20=E6=B2=99=E7=9B=92=E8=87=AA=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E9=80=83=E9=80=B8=E4=B8=8E=E6=8E=A5=E5=8F=A3=E7=89=A9?= =?UTF-8?q?=E4=BB=B6=E8=A2=AB=E5=89=A5=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沉浸式翻译等带 @grant 的 @inject-into content 脚本在 Firefox 上完全不工作。 根因有三处,都只在 Firefox 的 content world 触发:该 world 走 world: "USER_SCRIPT",全局是 Cu.Sandbox —— globalThis !== window,且 window / self / Node / NodeFilter 都不是它的自有属性(在原型上那个 982 属性的 Window 包装器里,而该包装器的原型是 null,结构反射到此为止)。 Chrome 下 global 本身就是 Window,三处判断都恒等成立,行为不变。 1. 自引用逃逸:自引用改写只认 desc.value === global,initOwnDescs 又只取 global 的自有描述符,于是沙盒里根本没有 window / self,with(this.$) 穿透到外层解析到 页面 window。脚本写在 self 上、再从 globalThis 读的对象因此丢失 (沉浸式翻译写 self.GM_fetch、读 globalThis.GM_fetch,于是回退原生 fetch, 跨域被拦,一个翻译请求都发不出去)。 改为 isRealmWindow(o) = o === global || o === window,并在描述符缺失时按真实 window 取值补出。 2. 接口物件被 bind 剥空:protoBaseDescs 分支对任何函数无差别 value.bind(root), 而 bind 的产物没有 prototype、也丢掉全部静态成员,Node.ELEMENT_NODE / NodeFilter.SHOW_TEXT / XMLHttpRequest.DONE 全变 undefined,依赖这些常量的 DOM 遍历静默失效(沉浸式翻译因此一个段落都不标记)。新增 isBindableMethod,只 bind 「小写字头且无 prototype」的方法 —— 这正是 shouldFnBind 注释里既有的规则 (「小写字头能筛掉 NodeFilter 之类 Interface」),此前该分支没有照做。 3. 恢复上一提交撤销的两趟描述符收集(#1692)。经消融实验确认必需:关掉第二趟后 globalThis.addEventListener 回到 undefined。原型链实测佐证 —— 从 global 出发 的结构反射在那个原型为 null 的 Window 包装器处截断,EventTarget.prototype 的 三个方法只能由 window 那条链补齐;而 Math 从 window 看是 0 个静态成员、从 global 看是 45 个,所以两个根必须分别取。 --- .../service/content/create_context.test.ts | 99 +++++++++++++ src/app/service/content/create_context.ts | 134 +++++++++++------- 2 files changed, 184 insertions(+), 49 deletions(-) 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 539fbd225..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 @@ -183,57 +195,72 @@ const protoBaseDescs: Record = Object.create(null); // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor // 主要是找出哪些 function值, setter/getter 需要替换 global window -getAllPropertyDescriptors(global, ([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(global); - overridedDescs[key] = { - ...desc, - value: boundValue, - }; - descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(global, key)) { - if (!protoBaseDescs[key]) { - if (typeof value === "function") { - const boundValue = value.bind(global); - protoBaseDescs[key] = { - ...desc, - value: boundValue, - }; - } else { - protoBaseDescs[key] = { ...desc }; - } - } - } - } 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 +// 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, - get: desc?.get?.bind(global), - set: desc?.set?.bind(global), + 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 }; + } + } + } + } 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); // 必须:子类属性覆盖父类属性 + } } } - } -}); + }); + +// 第一趟 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 @@ -286,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; @@ -299,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; }; }; @@ -358,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 () { @@ -369,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 不会报错但赋值不变)