diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..d097046bd 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, it, expect, 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"; const createScriptInfo = (metadata: Record = {}): TScriptInfo => ({ @@ -32,6 +32,78 @@ const createTestContext = (grants: string[], metadata: Record new Set(grants) ); +const createSplitRealmRoots = (): RealmRoots => { + const realmGlobal = Object.create(null) as Record; + const hostWindow = Object.create(null) as Record; + const hostWindowPrototype = Object.create(null) as Record; + const eventTarget = new EventTarget(); + + // Firefox USER_SCRIPT 的 realm global 會以 host window 作為原型。 + Object.setPrototypeOf(realmGlobal, hostWindow); + Object.setPrototypeOf(hostWindow, hostWindowPrototype); + + realmGlobal.realmOnly = "realm-value"; + Object.defineProperty(realmGlobal, "realmAccessor", { + configurable: true, + enumerable: true, + get() { + return this === realmGlobal ? "realm-receiver" : "wrong-receiver"; + }, + }); + + hostWindow.constructor = window.constructor; + hostWindow.EventTarget = EventTarget; + hostWindow.Node = Node; + hostWindow.NodeFilter = NodeFilter; + hostWindow.HTMLBodyElement = class HostHTMLBodyElement {}; + hostWindow.Event = Event; + hostWindow.XMLHttpRequest = class HostXMLHttpRequest { + static DONE = 4; + }; + hostWindow.document = document; + hostWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget); + hostWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); + hostWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); + hostWindow.dynamicHostMethod = { + dynamicHostMethod(this: unknown) { + return this; + }, + }.dynamicHostMethod; + let dynamicHostValue = "host-value"; + Object.defineProperty(hostWindow, "dynamicHostAccessor", { + configurable: true, + enumerable: true, + get() { + return this === hostWindow ? dynamicHostValue : "wrong-receiver"; + }, + set(value) { + dynamicHostValue = value; + }, + }); + const DynamicInterface = function DynamicInterface() {}; + (DynamicInterface as any).staticValue = "static-value"; + hostWindow.DynamicInterface = DynamicInterface; + hostWindowPrototype.dynamicPrototypeMethod = { + dynamicPrototypeMethod(this: unknown) { + return this; + }, + }.dynamicPrototypeMethod; + Object.defineProperty(hostWindow, "onload", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + Object.defineProperty(hostWindow, "oncustomcompat", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + + return { realmGlobal, hostWindow }; +}; + describe.concurrent("shouldFnBind", () => { it.concurrent("不处理非原生函数", () => { const o: Record = {}; @@ -211,6 +283,8 @@ describe.concurrent("createProxyContext", () => { const sandbox = createProxyContext(createTestContext([])); const setTimeoutForTest1 = sandbox.setTimeoutForTest1; + expect(setTimeoutForTest1.name).toBe("bound setTimeoutForTest1"); + expect("prototype" in setTimeoutForTest1).toBe(false); expect(() => setTimeoutForTest1(() => undefined, 0)).not.toThrow(); }); @@ -237,4 +311,381 @@ describe.concurrent("createProxyContext", () => { const sandbox = createProxyContext(createTestContext([])); 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.sequential("Firefox content world:globalThis 与 window 分属不同 realm", () => { + const createFakeWindow = (prototype: object | null) => { + const eventTarget = new EventTarget(); + return Object.assign(Object.create(prototype), { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + }); + }; + + 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 = createFakeWindow(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 = createFakeWindow(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 = createFakeWindow(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); + }); + }); + + describe.concurrent("split-global materialization", () => { + it.concurrent("keeps GM APIs and writes on every global alias in the script sandbox", () => { + const roots = createSplitRealmRoots(); + const sandbox = createProxyContext(createTestContext(["GM_getValue"]), roots); + + const getValue = Reflect.get(sandbox.window, "GM_getValue") as (key: string) => unknown; + expect(getValue("foo")).toBe("bar"); + + Reflect.set(sandbox.self, "__split_global_alias_value", "sandbox-value"); + + expect(Reflect.get(sandbox.window, "__split_global_alias_value")).toBe("sandbox-value"); + expect(Reflect.get(sandbox.globalThis, "__split_global_alias_value")).toBe("sandbox-value"); + expect(Reflect.get(roots.hostWindow, "__split_global_alias_value")).toBeUndefined(); + }); + + it.concurrent("keeps JavaScript intrinsics from realmGlobal when hostWindow is a different root", () => { + const roots = createSplitRealmRoots(); + const realmMath = { max: () => "realm" }; + const hostMath = { max: () => "host" }; + roots.realmGlobal.Math = realmMath; + roots.hostWindow.Math = hostMath; + + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.Math).toBe(realmMath); + expect(sandbox.Math).not.toBe(hostMath); + }); + + it.concurrent("collects dynamic host own and prototype descriptors", () => { + const roots = createSplitRealmRoots(); + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.dynamicHostMethod()).toBe(roots.hostWindow); + expect(sandbox.dynamicHostAccessor).toBe("host-value"); + expect(sandbox.dynamicPrototypeMethod()).toBe(roots.hostWindow); + expect(sandbox.DynamicInterface.staticValue).toBe("static-value"); + expect(sandbox.DynamicInterface.prototype).toBe(roots.hostWindow.DynamicInterface.prototype); + + sandbox.dynamicHostAccessor = "updated-value"; + expect(sandbox.dynamicHostAccessor).toBe("updated-value"); + }); + + it.concurrent("preserves non-self top, parent, and frames references from an iframe realm", () => { + const roots = createSplitRealmRoots(); + const parentWindow = Object.create(null); + const topWindow = Object.create(null); + const frames = Object.create(null); + roots.hostWindow.parent = parentWindow; + roots.hostWindow.top = topWindow; + roots.hostWindow.frames = frames; + + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.parent).toBe(parentWindow); + expect(sandbox.top).toBe(topWindow); + expect(sandbox.frames).toBe(frames); + }); + + it.concurrent("materializes separate realm and host roots through the eager snapshot", () => { + const roots = createSplitRealmRoots(); + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.Node).toBe(roots.hostWindow.Node); + expect(sandbox.Node.prototype).toBe(roots.hostWindow.Node.prototype); + expect(sandbox.NodeFilter).toBe(roots.hostWindow.NodeFilter); + expect(sandbox.NodeFilter.SHOW_TEXT).toBe(roots.hostWindow.NodeFilter.SHOW_TEXT); + expect(sandbox.HTMLBodyElement).toBe(roots.hostWindow.HTMLBodyElement); + expect(sandbox.HTMLBodyElement.prototype).toBe(roots.hostWindow.HTMLBodyElement.prototype); + expect(sandbox.XMLHttpRequest).toBe(roots.hostWindow.XMLHttpRequest); + expect(sandbox.XMLHttpRequest.DONE).toBe(4); + expect(sandbox.EventTarget).toBe(roots.hostWindow.EventTarget); + expect(sandbox.document).toBe(document); + expect(sandbox.realmOnly).toBe("realm-value"); + expect(sandbox.realmAccessor).toBe("realm-receiver"); + + const listener = vi.fn(); + sandbox.addEventListener("split-root", listener); + roots.hostWindow.dispatchEvent(new Event("split-root")); + expect(listener).toHaveBeenCalledTimes(1); + sandbox.removeEventListener("split-root", listener); + + const onload = vi.fn(); + Reflect.set(sandbox, "onload", onload); + roots.hostWindow.dispatchEvent(new Event("load")); + Reflect.set(sandbox, "onload", null); + roots.hostWindow.dispatchEvent(new Event("load")); + expect(onload).toHaveBeenCalledTimes(1); + + const customCompatHandler = vi.fn(); + Reflect.set(sandbox, "oncustomcompat", customCompatHandler); + roots.hostWindow.dispatchEvent(new Event("customcompat")); + Reflect.set(sandbox, "oncustomcompat", null); + expect(customCompatHandler).toHaveBeenCalledTimes(1); + }); + + it.concurrent("keeps all self-referential window names inside the sandbox", () => { + const roots = createSplitRealmRoots(); + roots.hostWindow.top = roots.hostWindow; + roots.hostWindow.parent = roots.hostWindow; + roots.hostWindow.frames = roots.hostWindow; + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.window).toBe(sandbox); + expect(sandbox.self).toBe(sandbox); + expect(sandbox.globalThis).toBe(sandbox); + expect(sandbox.top).toBe(sandbox); + expect(sandbox.parent).toBe(sandbox); + expect(sandbox.frames).toBe(sandbox); + }); + + it.concurrent("preserves host constructor static constants and prototype identity", () => { + const sandbox = createProxyContext(createTestContext([])); + + expect(sandbox.Node).toBe(window.Node); + expect(sandbox.Node.ELEMENT_NODE).toBe(window.Node.ELEMENT_NODE); + expect(sandbox.Node.prototype).toBe(window.Node.prototype); + expect(sandbox.Event).toBe(window.Event); + expect(sandbox.Event.prototype).toBe(window.Event.prototype); + }); + + it.concurrent("keeps JavaScript built-in static methods available", () => { + const sandbox = createProxyContext(createTestContext([])); + + expect(sandbox.Number.isNaN).toBe(Number.isNaN); + expect(sandbox.Math.max(2, 7)).toBe(7); + expect(sandbox.Object.isFrozen(Object.freeze({}))).toBe(true); + }); + + it.concurrent("forwards extracted host methods with the host receiver", () => { + const sandbox = createProxyContext(createTestContext([])); + const add = sandbox.addEventListener; + const remove = sandbox.removeEventListener; + const dispatch = sandbox.dispatchEvent; + const eventName = "__scriptcat_split_global_event"; + const listener = vi.fn(); + + add(eventName, listener); + dispatch(new Event(eventName)); + remove(eventName, listener); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it.concurrent("does not register an event listener for an object handler", () => { + const sandbox = createProxyContext(createTestContext([])); + const listenerObject = { handleEvent: vi.fn() }; + + Reflect.set(sandbox, "onfocus", listenerObject); + window.dispatchEvent(new Event("focus")); + + expect(listenerObject.handleEvent).not.toHaveBeenCalled(); + sandbox.onfocus = null; + }); + + it.concurrent("removes the old event listener when an on-property is cleared", () => { + const sandbox = createProxyContext(createTestContext([])); + const handler = vi.fn(); + + sandbox.onresize = handler; + window.dispatchEvent(new Event("resize")); + sandbox.onresize = null; + window.dispatchEvent(new Event("resize")); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it.concurrent("removes a function listener before storing an object handler, then accepts a new function", () => { + const sandbox = createProxyContext(createTestContext([])); + const oldHandler = vi.fn(); + const objectHandler = { handleEvent: vi.fn() }; + const newHandler = vi.fn(); + + try { + sandbox.onblur = oldHandler; + Reflect.set(sandbox, "onblur", objectHandler); + window.dispatchEvent(new Event("blur")); + + expect(oldHandler).not.toHaveBeenCalled(); + expect(objectHandler.handleEvent).not.toHaveBeenCalled(); + + sandbox.onblur = newHandler; + window.dispatchEvent(new Event("blur")); + expect(newHandler).toHaveBeenCalledTimes(1); + } finally { + sandbox.onblur = null; + } + }); + + it.concurrent("replaces an on-property listener without retaining the previous callback", () => { + const sandbox = createProxyContext(createTestContext([])); + const oldHandler = vi.fn(); + const newHandler = vi.fn(); + + try { + sandbox.onhashchange = oldHandler; + sandbox.onhashchange = newHandler; + window.dispatchEvent(new Event("hashchange")); + + expect(oldHandler).not.toHaveBeenCalled(); + expect(newHandler).toHaveBeenCalledTimes(1); + } finally { + sandbox.onhashchange = null; + } + }); + + it.concurrent("isolates writes between split-global sandboxes", () => { + const first = createProxyContext(createTestContext([])); + const second = createProxyContext(createTestContext([])); + + first.__split_global_local_value = "first"; + + expect(first.__split_global_local_value).toBe("first"); + expect(second.__split_global_local_value).toBeUndefined(); + expect(Reflect.get(window, "__split_global_local_value")).toBeUndefined(); + }); + + it.concurrent("keeps the page window identity separate from the sandbox identity", () => { + const sandbox = createProxyContext(createTestContext([])); + + expect(sandbox).not.toBe(window); + expect(sandbox.unsafeWindow).toBe(window); + }); + + it.concurrent( + "uses hostWindow as the receiver for host prototype accessors and keeps the nearest descriptor", + () => { + const roots = createSplitRealmRoots(); + const parentPrototype = Object.create(null); + const hostPrototype = Object.create(parentPrototype); + 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 === roots.hostWindow ? "host" : "wrong-receiver"; + }, + set(value: string) { + hostValue = this === roots.hostWindow ? value : "wrong-receiver"; + }, + }); + Object.defineProperty(hostPrototype, "hostAccessor", { + configurable: true, + enumerable: true, + get() { + return this === roots.hostWindow ? "host" : "wrong-receiver"; + }, + set(value: string) { + hostValue = this === roots.hostWindow ? value : "wrong-receiver"; + }, + }); + Object.setPrototypeOf(roots.hostWindow, hostPrototype); + + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(sandbox.precedenceAccessor).toBe("host"); + expect(sandbox.hostAccessor).toBe("host"); + sandbox.hostAccessor = "updated"; + expect(hostValue).toBe("updated"); + } + ); + }); }); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 165e6819e..d2fb32c54 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"; @@ -155,139 +154,175 @@ export const shouldFnBind = (f: any) => { return false; }; -type ForEachCallback = (value: T, index: number, array: T[]) => void; - // 取物件本身及所有父类(不包含Object)的PropertyDescriptor -const getAllPropertyDescriptors = (obj: any, callback: ForEachCallback<[string | symbol, PropertyDescriptor]>) => { +type DescriptorOwner = Record; + +type DescriptorMap = Record; + +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); +// 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; +}; -// overridedDescs将以物件OwnPropertyDescriptor方式进行物件属性修改 -// 覆盖原有的 OwnPropertyDescriptor定义 或 父类的PropertyDescriptor定义 -const overridedDescs: Record = Object.create(null); +const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { + if ("value" in descriptor) { + if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; + return { + ...descriptor, + value: Function.prototype.bind.call(descriptor.value, receiver), + }; + } + if (!descriptor.get && !descriptor.set) return descriptor; + return { + ...descriptor, + get: descriptor.get?.bind(receiver), + set: descriptor.set?.bind(receiver), + }; +}; -// 记录原生 onxxxxx 的 PropertyDescriptor -const eventDescs: Record = Object.create(null); +type GlobalSnapshot = { + sharedInitCopy: typeof globalThis & Record; + eventKeys: Set; +}; -// 在 USE_PSEUDO_WINDOW 情况下,由于没有 类的prototype, 父类的成员要手动传下去 -const protoBaseDescs: Record = Object.create(null); +export type RealmRoots = { + realmGlobal: DescriptorOwner; + hostWindow: DescriptorOwner; +}; -// 包含物件本身及所有父类(不包含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; +const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { + // descsCache 记录已处理的属性;先处理的 descriptor 覆盖后续父类。 + const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + // overriddenDescs 会以 sandbox own descriptor 覆盖原有定义;eventKeys 只记录需要模拟的 on* 属性。 + const overriddenDescs: DescriptorMap = Object.create(null); + const eventKeys = new Set(); + 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; + + if ("value" in desc) { + // 原生 function 绑定到所属 root;constructor/interface 保留原值。 + if (desc.writable && shouldFnBind(desc.value)) { + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); + descsCache.add(key); // 必须:子类属性覆盖父类属性 + } + continue; + } - if (desc.writable) { - // 属性 value + if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { + // 替换 onxxxxx 事件赋值操作。 + eventKeys.add(key); + continue; + } + if (desc.get || desc.set) { + // 替换 getter/setter 的 this 为实际的 global window。 + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); + descsCache.add(key); // 必须:子类属性覆盖父类属性 + } + } + }; - const value = desc.value; + const collectHostWindowDescriptors = () => { + getAllPropertyDescriptors(hostWindow, (key, desc) => { + if (!desc || typeof key !== "string") return; - // 替换 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]) { - if (typeof value === "function") { - const boundValue = value.bind(root); - protoBaseDescs[key] = { - ...desc, - value: boundValue, - }; - } else { - protoBaseDescs[key] = { ...desc }; - } - } + if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { + eventKeys.add(key); + return; } - } 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); // 必须:子类属性覆盖父类属性 + if (descsCache.has(key)) return; + + if ("value" in desc) { + 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) { + 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, }); -// 第一趟 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 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); @@ -298,22 +333,21 @@ type GMWorldContext = typeof globalThis & Record; const isPrimitive = (x: any) => x !== Object(x); // 拦截上下文 -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 (ret === global) return mySandbox; - return ret; - }; - }; + const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); + const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -323,9 +357,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); @@ -347,11 +381,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; @@ -360,7 +394,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], @@ -368,25 +402,33 @@ export const createProxyContext = (context }; } - for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) { - const desc = ownDescs[key]; - if (desc?.value === global) { - // 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) { @@ -422,7 +464,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"]) { @@ -457,7 +500,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