From 3e6807292e5984b3719748895e3399502bcdcd9d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:54:20 +0900 Subject: [PATCH 01/23] fix sandbox ff spec --- .../service/content/create_context.test.ts | 187 ++++++++- src/app/service/content/create_context.ts | 381 ++++++++++++------ 2 files changed, 445 insertions(+), 123 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..675b28844 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,45 @@ 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 eventTarget = new EventTarget(); + + realmGlobal.Node = class RealmNode {}; + realmGlobal.XMLHttpRequest = class RealmXMLHttpRequest {}; + 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.Event = Event; + hostWindow.XMLHttpRequest = class HostXMLHttpRequest { + static DONE = 4; + }; + hostWindow.document = document; + hostWindow.hostOnly = "host-value"; + hostWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget); + hostWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); + hostWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); + Object.defineProperty(hostWindow, "onload", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + + return { realmGlobal, hostWindow }; +}; + describe.concurrent("shouldFnBind", () => { it.concurrent("不处理非原生函数", () => { const o: Record = {}; @@ -237,4 +276,150 @@ describe.concurrent("createProxyContext", () => { const sandbox = createProxyContext(createTestContext([])); expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); }); + + describe.concurrent("split-global materialization", () => { + 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.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"); + expect(sandbox.hostOnly).toBeUndefined(); + + 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); + }); + + it.concurrent("keeps all self-referential window names inside the sandbox", () => { + const sandbox = createProxyContext(createTestContext([])); + + 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("forwards live host accessors without leaking page globals", () => { + const sandbox = createProxyContext(createTestContext([])); + const pageKey = "__scriptcat_split_global_page_value"; + + Reflect.set(window, pageKey, "page-value"); + try { + expect(sandbox.document).toBe(window.document); + expect(sandbox.location).toBe(window.location); + expect(sandbox[pageKey]).toBeUndefined(); + } finally { + Reflect.deleteProperty(window, pageKey); + } + }); + + 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("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); + }); + }); }); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 165e6819e..7097a3ca4 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -155,39 +155,166 @@ 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 DescriptorEntry = [string | symbol, PropertyDescriptor, DescriptorOwner]; +type TrackedDescriptor = { + descriptor: PropertyDescriptor; + owner: DescriptorOwner; + receiver: DescriptorOwner; + isConstructor: boolean; +}; + +const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: DescriptorEntry) => void) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); - Object.entries(descs).forEach(callback); + Reflect.ownKeys(descs).forEach((key) => callback([key, descs[key as keyof typeof descs], obj])); obj = Object.getPrototypeOf(obj); } }; -// 在 CacheSet 加入的propKeys将会在 mySandbox 实装阶段时设置 -const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +const isConstructorOrInterface = (value: unknown): value is Function => + typeof value === "function" && ("prototype" in value || /^[A-Z]/.test(String(Reflect.get(value, "name")))); -const initOwnDescs = Object.getOwnPropertyDescriptors(global); +const materializeDescriptor = (key: string, tracked: TrackedDescriptor): PropertyDescriptor => { + const { descriptor, owner, receiver, isConstructor } = tracked; + if ("value" in descriptor) { + if (typeof descriptor.value !== "function" || isConstructor) return { ...descriptor }; + return { + ...descriptor, + value: function (this: any, ...args: any[]) { + return Reflect.apply(descriptor.value, receiver, args); + }, + }; + } + return { + ...descriptor, + get: descriptor.get ? () => Reflect.get(owner, key, receiver) : undefined, + set: descriptor.set + ? (value: any) => { + Reflect.set(owner, key, value, receiver); + } + : undefined, + }; +}; -// overridedDescs将以物件OwnPropertyDescriptor方式进行物件属性修改 -// 覆盖原有的 OwnPropertyDescriptor定义 或 父类的PropertyDescriptor定义 -const overridedDescs: Record = Object.create(null); +// Firefox 的 content / USER_SCRIPT world 将 JavaScript global 与页面 window 拆成两个 realm。 +// 这里仅转发确定需要页面 brand 的成员;不遍历第二个根,避免把页面全局快照带入沙盒。 +const hostWindowAccessors = [ + "document", + "location", + "navigator", + "history", + "screen", + "performance", + "crypto", + "localStorage", + "sessionStorage", + "visualViewport", + "innerWidth", + "innerHeight", + "scrollX", + "scrollY", + "devicePixelRatio", +]; +const hostWindowMethods = [ + "addEventListener", + "removeEventListener", + "dispatchEvent", + "getComputedStyle", + "matchMedia", + "requestAnimationFrame", + "cancelAnimationFrame", + "scroll", + "scrollTo", + "scrollBy", + "blur", +]; +const hostWindowConstructors = [ + "Window", + "EventTarget", + "Node", + "Element", + "HTMLElement", + "Document", + "DocumentFragment", + "ShadowRoot", + "Text", + "Range", + "MutationObserver", + "NodeFilter", + "TreeWalker", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "PointerEvent", + "InputEvent", + "FocusEvent", + "ErrorEvent", + "ProgressEvent", + "MessageEvent", + "StorageEvent", + "WheelEvent", + "DragEvent", + "ClipboardEvent", + "DOMParser", + "XMLSerializer", + "FormData", + "File", + "FileList", + "Blob", + "URL", + "URLSearchParams", + "Headers", + "Request", + "Response", + "XMLHttpRequest", +]; +const hostWindowEventProperties = ["onload", "onerror", "onresize", "onfocus", "onblur", "onhashchange"]; + +const hostWindowKeys = new Set([...hostWindowAccessors, ...hostWindowMethods, ...hostWindowConstructors]); + +type GlobalSnapshot = { + sharedInitCopy: typeof globalThis & Record; + eventDescs: Record; +}; -// 记录原生 onxxxxx 的 PropertyDescriptor -const eventDescs: Record = Object.create(null); +export type RealmRoots = { + realmGlobal: DescriptorOwner; + hostWindow: DescriptorOwner; +}; -// 在 USE_PSEUDO_WINDOW 情况下,由于没有 类的prototype, 父类的成员要手动传下去 -const protoBaseDescs: Record = Object.create(null); +const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { + const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + const overridedDescs: Record = Object.create(null); + const eventDescs: Record = Object.create(null); + const protoBaseDescs: Record = Object.create(null); + + const getHostWindowDescriptor = (key: string): PropertyDescriptor | undefined => { + let owner: DescriptorOwner | null = hostWindow; + while (owner) { + const descriptor = Object.getOwnPropertyDescriptor(owner, key); + if (descriptor) return descriptor; + owner = Object.getPrototypeOf(owner); + } + return undefined; + }; -// 包含物件本身及所有父类(不包含Object)的PropertyDescriptor -// 主要是找出哪些 function值, setter/getter 需要替换 global window -// bind 目标跟随该轮的根物件 root,因为两个根分属不同 realm,互相绑定会触发 brand check 失败 -const collectPropertyDescriptors = (root: any) => - getAllPropertyDescriptors(root, ([key, desc]) => { + // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor + // 主要是找出哪些 function值, setter/getter 需要替换 global window + getAllPropertyDescriptors(realmGlobal, ([key, desc, owner]) => { if (!desc || descsCache.has(key) || typeof key !== "string") return; + const tracked = { + descriptor: desc, + owner, + receiver: realmGlobal, + isConstructor: isConstructorOrInterface(desc.value), + }; + if (desc.writable) { // 属性 value @@ -198,23 +325,11 @@ const collectPropertyDescriptors = (root: any) => // 对于构造函数和类(有 prototype 属性),shouldFnBind 会返回 false,跳过绑定 // 因此被封装的属性,会略过封装层,继续向父类寻找原生属性 if (shouldFnBind(value)) { - const boundValue = value.bind(root); - overridedDescs[key] = { - ...desc, - value: boundValue, - }; + overridedDescs[key] = materializeDescriptor(key, tracked); descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(root, key)) { + } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key)) { if (!protoBaseDescs[key]) { - if (typeof value === "function") { - const boundValue = value.bind(root); - protoBaseDescs[key] = { - ...desc, - value: boundValue, - }; - } else { - protoBaseDescs[key] = { ...desc }; - } + protoBaseDescs[key] = materializeDescriptor(key, tracked); } } } else { @@ -226,68 +341,101 @@ const collectPropertyDescriptors = (root: any) => 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), - }; + overridedDescs[key] = materializeDescriptor(key, tracked); descsCache.add(key); // 必须:子类属性覆盖父类属性 } } } }); + descsCache.clear(); // 内存释放 + + const addHostWindowForwarding = (key: string) => { + if (!(key in hostWindow)) return; + const hostDescriptor = getHostWindowDescriptor(key); + if (hostWindowAccessors.includes(key)) { + overridedDescs[key] = { + configurable: true, + enumerable: true, + get: () => Reflect.get(hostWindow, key, hostWindow), + ...(hostDescriptor?.set + ? { + set: (value: any) => { + Reflect.set(hostWindow, key, value, hostWindow); + }, + } + : {}), + }; + } else if (hostWindowMethods.includes(key)) { + overridedDescs[key] = { + configurable: true, + enumerable: true, + writable: true, + value: function (...args: any[]) { + const method = Reflect.get(hostWindow, key, hostWindow); + return Reflect.apply(method, hostWindow, args); + }, + }; + } else { + overridedDescs[key] = { + configurable: true, + enumerable: true, + writable: true, + value: Reflect.get(hostWindow, key, hostWindow), + }; + } + }; -// 第一趟 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, - }); + for (const key of hostWindowKeys) addHostWindowForwarding(key); + for (const key of hostWindowEventProperties) { + if (key in hostWindow && !eventDescs[key]) eventDescs[key] = { configurable: true, enumerable: true }; + } + + // 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: 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, + ...overridedDescs, + }) + : Object.create(Object.getPrototypeOf(realmGlobal), { + ...initOwnDescs, + ...overridedDescs, + }); + + return { sharedInitCopy, eventDescs }; +}; + +const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 const initConsoleDescs = Object.getOwnPropertyDescriptors(console); @@ -298,22 +446,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, eventDescs } = + 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 +470,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 +494,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; @@ -369,24 +516,13 @@ 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 () { + 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; - } + }, + }; } if (noEval) { if (ownDescs?.eval?.value) { @@ -422,7 +558,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 +594,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 From a63891090c05035f22cd7a68509428f178c61515 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:11:07 +0900 Subject: [PATCH 02/23] fix --- .../service/content/create_context.test.ts | 14 +++++ src/app/service/content/create_context.ts | 57 ++++++++++--------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 675b28844..5574f0ec7 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -67,6 +67,12 @@ const createSplitRealmRoots = (): RealmRoots => { get: () => null, set: () => undefined, }); + Object.defineProperty(hostWindow, "oncustomcompat", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); return { realmGlobal, hostWindow }; }; @@ -250,6 +256,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(); }); @@ -304,6 +312,12 @@ describe.concurrent("createProxyContext", () => { 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", () => { diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 7097a3ca4..5e29c16fc 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -157,10 +157,9 @@ export const shouldFnBind = (f: any) => { // 取物件本身及所有父类(不包含Object)的PropertyDescriptor type DescriptorOwner = Record; -type DescriptorEntry = [string | symbol, PropertyDescriptor, DescriptorOwner]; +type DescriptorEntry = [string | symbol, PropertyDescriptor]; type TrackedDescriptor = { descriptor: PropertyDescriptor; - owner: DescriptorOwner; receiver: DescriptorOwner; isConstructor: boolean; }; @@ -168,7 +167,7 @@ type TrackedDescriptor = { const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: DescriptorEntry) => void) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); - Reflect.ownKeys(descs).forEach((key) => callback([key, descs[key as keyof typeof descs], obj])); + Reflect.ownKeys(descs).forEach((key) => callback([key, descs[key as keyof typeof descs]])); obj = Object.getPrototypeOf(obj); } }; @@ -177,25 +176,19 @@ const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: Descr const isConstructorOrInterface = (value: unknown): value is Function => typeof value === "function" && ("prototype" in value || /^[A-Z]/.test(String(Reflect.get(value, "name")))); -const materializeDescriptor = (key: string, tracked: TrackedDescriptor): PropertyDescriptor => { - const { descriptor, owner, receiver, isConstructor } = tracked; +const materializeDescriptor = (tracked: TrackedDescriptor): PropertyDescriptor => { + const { descriptor, receiver, isConstructor } = tracked; if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructor) return { ...descriptor }; return { ...descriptor, - value: function (this: any, ...args: any[]) { - return Reflect.apply(descriptor.value, receiver, args); - }, + value: Function.prototype.bind.call(descriptor.value, receiver), }; } return { ...descriptor, - get: descriptor.get ? () => Reflect.get(owner, key, receiver) : undefined, - set: descriptor.set - ? (value: any) => { - Reflect.set(owner, key, value, receiver); - } - : undefined, + get: descriptor.get?.bind(receiver), + set: descriptor.set?.bind(receiver), }; }; @@ -272,8 +265,6 @@ const hostWindowConstructors = [ "Response", "XMLHttpRequest", ]; -const hostWindowEventProperties = ["onload", "onerror", "onresize", "onfocus", "onblur", "onhashchange"]; - const hostWindowKeys = new Set([...hostWindowAccessors, ...hostWindowMethods, ...hostWindowConstructors]); type GlobalSnapshot = { @@ -305,12 +296,11 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor // 主要是找出哪些 function值, setter/getter 需要替换 global window - getAllPropertyDescriptors(realmGlobal, ([key, desc, owner]) => { + getAllPropertyDescriptors(realmGlobal, ([key, desc]) => { if (!desc || descsCache.has(key) || typeof key !== "string") return; const tracked = { descriptor: desc, - owner, receiver: realmGlobal, isConstructor: isConstructorOrInterface(desc.value), }; @@ -325,11 +315,11 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 对于构造函数和类(有 prototype 属性),shouldFnBind 会返回 false,跳过绑定 // 因此被封装的属性,会略过封装层,继续向父类寻找原生属性 if (shouldFnBind(value)) { - overridedDescs[key] = materializeDescriptor(key, tracked); + overridedDescs[key] = materializeDescriptor(tracked); descsCache.add(key); // 必须:子类属性覆盖父类属性 } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key)) { if (!protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(key, tracked); + protoBaseDescs[key] = materializeDescriptor(tracked); } } } else { @@ -341,7 +331,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.get || desc.set) { // 替换 getter setter 的 this 为 实际的 global window // 例:(window.)location, (window.)document - overridedDescs[key] = materializeDescriptor(key, tracked); + overridedDescs[key] = materializeDescriptor(tracked); descsCache.add(key); // 必须:子类属性覆盖父类属性 } } @@ -349,6 +339,22 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); descsCache.clear(); // 内存释放 + const hostEventKeys = new Set(); + getAllPropertyDescriptors(hostWindow, ([key, desc]) => { + if ( + typeof key !== "string" || + hostEventKeys.has(key) || + !key.startsWith("on") || + !desc.configurable || + !desc.get || + !desc.set + ) { + return; + } + eventDescs[key] = desc; + hostEventKeys.add(key); + }); + const addHostWindowForwarding = (key: string) => { if (!(key in hostWindow)) return; const hostDescriptor = getHostWindowDescriptor(key); @@ -366,14 +372,12 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn : {}), }; } else if (hostWindowMethods.includes(key)) { + const method = Reflect.get(hostWindow, key, hostWindow); overridedDescs[key] = { configurable: true, enumerable: true, writable: true, - value: function (...args: any[]) { - const method = Reflect.get(hostWindow, key, hostWindow); - return Reflect.apply(method, hostWindow, args); - }, + value: typeof method === "function" ? Function.prototype.bind.call(method, hostWindow) : method, }; } else { overridedDescs[key] = { @@ -386,9 +390,6 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }; for (const key of hostWindowKeys) addHostWindowForwarding(key); - for (const key of hostWindowEventProperties) { - if (key in hostWindow && !eventDescs[key]) eventDescs[key] = { configurable: true, enumerable: true }; - } // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) From 7c54365f649ee80206a22fbfbee423a8db65bbfe Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:23:35 +0900 Subject: [PATCH 03/23] =?UTF-8?q?vitest:=20Firefox=20content=20world?= =?UTF-8?q?=EF=BC=9AglobalThis=20=E4=B8=8E=20window=20=E5=88=86=E5=B1=9E?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=20realm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 5574f0ec7..f1798b01d 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -285,6 +285,105 @@ 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); + }); + }); + describe.concurrent("split-global materialization", () => { it.concurrent("materializes separate realm and host roots through the eager snapshot", () => { const roots = createSplitRealmRoots(); From f0c7c95da61269d0167a8384ab41a62084712524 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:47:17 +0900 Subject: [PATCH 04/23] fix --- .../service/content/create_context.test.ts | 17 ++++++--- src/app/service/content/create_context.ts | 35 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index f1798b01d..26f6cffda 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -288,7 +288,16 @@ describe.concurrent("createProxyContext", () => { // 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", () => { + 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(); @@ -302,7 +311,7 @@ describe.concurrent("createProxyContext", () => { return this; }, }.onlyReachableViaWindow; - const fakeWindow = Object.create(windowProto); + const fakeWindow = createFakeWindow(windowProto); vi.stubGlobal("window", fakeWindow); vi.resetModules(); @@ -336,7 +345,7 @@ describe.concurrent("createProxyContext", () => { (FilterLike as any).SHOW_TEXT = 4; windowProto.FilterLike = FilterLike; - const fakeWindow = Object.create(windowProto); + const fakeWindow = createFakeWindow(windowProto); vi.stubGlobal("window", fakeWindow); vi.resetModules(); @@ -361,7 +370,7 @@ describe.concurrent("createProxyContext", () => { // Firefox 下 globalThis.window 是页面 Window 的 Xray 包装,不等于 global; // 只按 global 判定自引用会让沙盒里的 window / self 指回页面, // 脚本写在 self 上的东西(例如沉浸式翻译的 GM_fetch)就落到了页面而不是沙盒。 - const pageWindow: Record = Object.create(null); + const pageWindow: Record = createFakeWindow(null); pageWindow.window = pageWindow; pageWindow.self = pageWindow; vi.stubGlobal("window", pageWindow); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 5e29c16fc..2ec1bd57d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -193,7 +193,7 @@ const materializeDescriptor = (tracked: TrackedDescriptor): PropertyDescriptor = }; // Firefox 的 content / USER_SCRIPT world 将 JavaScript global 与页面 window 拆成两个 realm。 -// 这里仅转发确定需要页面 brand 的成员;不遍历第二个根,避免把页面全局快照带入沙盒。 +// 这里只读取 hostWindow 的原型链,并转发确定需要页面 brand 的成员,避免把页面全局 own properties 带入沙盒。 const hostWindowAccessors = [ "document", "location", @@ -337,9 +337,40 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } } }); + const hostEventKeys = new Set(); + const hostWindowPrototype = Object.getPrototypeOf(hostWindow); + if (hostWindowPrototype) { + getAllPropertyDescriptors(hostWindowPrototype, ([key, desc]) => { + if (!desc || descsCache.has(key) || typeof key !== "string") return; + + if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { + eventDescs[key] = desc; + hostEventKeys.add(key); + return; + } + + const tracked = { + descriptor: desc, + receiver: hostWindow, + isConstructor: isConstructorOrInterface(desc.value), + }; + + if (desc.writable) { + if (shouldFnBind(desc.value)) { + overridedDescs[key] = materializeDescriptor(tracked); + descsCache.add(key); + } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + protoBaseDescs[key] = materializeDescriptor(tracked); + } + } else if (desc.get || desc.set) { + overridedDescs[key] = materializeDescriptor(tracked); + descsCache.add(key); + } + }); + } + descsCache.clear(); // 内存释放 - const hostEventKeys = new Set(); getAllPropertyDescriptors(hostWindow, ([key, desc]) => { if ( typeof key !== "string" || From 970c3e2ea92a7e76d6c55d726505a591b0009c3b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:50:32 +0900 Subject: [PATCH 05/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E9=87=8D=E6=A7=8B?= =?UTF-8?q?=20content=20sandbox=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 177 ++++++++++------------ 1 file changed, 84 insertions(+), 93 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 2ec1bd57d..6a9454bf2 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"; @@ -164,6 +163,8 @@ type TrackedDescriptor = { isConstructor: boolean; }; +type DescriptorMap = Record; + const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: DescriptorEntry) => void) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); @@ -176,8 +177,13 @@ const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: Descr const isConstructorOrInterface = (value: unknown): value is Function => typeof value === "function" && ("prototype" in value || /^[A-Z]/.test(String(Reflect.get(value, "name")))); -const materializeDescriptor = (tracked: TrackedDescriptor): PropertyDescriptor => { - const { descriptor, receiver, isConstructor } = tracked; +const trackDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): TrackedDescriptor => ({ + descriptor, + receiver, + isConstructor: isConstructorOrInterface(descriptor.value), +}); + +const materializeDescriptor = ({ descriptor, receiver, isConstructor }: TrackedDescriptor): PropertyDescriptor => { if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructor) return { ...descriptor }; return { @@ -194,7 +200,7 @@ const materializeDescriptor = (tracked: TrackedDescriptor): PropertyDescriptor = // Firefox 的 content / USER_SCRIPT world 将 JavaScript global 与页面 window 拆成两个 realm。 // 这里只读取 hostWindow 的原型链,并转发确定需要页面 brand 的成员,避免把页面全局 own properties 带入沙盒。 -const hostWindowAccessors = [ +const hostWindowAccessors = new Set([ "document", "location", "navigator", @@ -210,8 +216,8 @@ const hostWindowAccessors = [ "scrollX", "scrollY", "devicePixelRatio", -]; -const hostWindowMethods = [ +]); +const hostWindowMethods = new Set([ "addEventListener", "removeEventListener", "dispatchEvent", @@ -223,8 +229,8 @@ const hostWindowMethods = [ "scrollTo", "scrollBy", "blur", -]; -const hostWindowConstructors = [ +]); +const hostWindowConstructors = new Set([ "Window", "EventTarget", "Node", @@ -264,12 +270,12 @@ const hostWindowConstructors = [ "Request", "Response", "XMLHttpRequest", -]; +]); const hostWindowKeys = new Set([...hostWindowAccessors, ...hostWindowMethods, ...hostWindowConstructors]); type GlobalSnapshot = { sharedInitCopy: typeof globalThis & Record; - eventDescs: Record; + eventKeys: Set; }; export type RealmRoots = { @@ -280,9 +286,10 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); - const overridedDescs: Record = Object.create(null); - const eventDescs: Record = Object.create(null); - const protoBaseDescs: Record = Object.create(null); + const overriddenDescs: DescriptorMap = Object.create(null); + const eventKeys = new Set(); + const hostEventKeys = new Set(); + const protoBaseDescs: DescriptorMap = Object.create(null); const getHostWindowDescriptor = (key: string): PropertyDescriptor | undefined => { let owner: DescriptorOwner | null = hostWindow; @@ -294,103 +301,82 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn return undefined; }; - // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor - // 主要是找出哪些 function值, setter/getter 需要替换 global window - getAllPropertyDescriptors(realmGlobal, ([key, desc]) => { - if (!desc || descsCache.has(key) || typeof key !== "string") return; - - const tracked = { - descriptor: desc, - receiver: realmGlobal, - isConstructor: isConstructorOrInterface(desc.value), - }; - - if (desc.writable) { - // 属性 value - - const value = desc.value; + const collectRealmDescriptors = () => { + // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor。 + // 主要是找出哪些 function 值、setter/getter 需要替换 global window。 + getAllPropertyDescriptors(realmGlobal, ([key, desc]) => { + if (!desc || descsCache.has(key) || typeof key !== "string") return; - // 替换 function 的 this 为 实际的 global window - // 例:父类的 addEventListener - // 对于构造函数和类(有 prototype 属性),shouldFnBind 会返回 false,跳过绑定 - // 因此被封装的属性,会略过封装层,继续向父类寻找原生属性 - if (shouldFnBind(value)) { - overridedDescs[key] = materializeDescriptor(tracked); - descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key)) { - if (!protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(tracked); + if (desc.writable) { + // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性。 + if (shouldFnBind(desc.value)) { + overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); + descsCache.add(key); // 必须:子类属性覆盖父类属性 + } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + protoBaseDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); } + 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] = materializeDescriptor(tracked); - descsCache.add(key); // 必须:子类属性覆盖父类属性 - } + // 替换 onxxxxx 事件赋值操作,例如 (window.)onload、(window.)onerror。 + eventKeys.add(key); + } else if (desc.get || desc.set) { + // 替换 getter/setter 的 this 为实际的 global window,例如 (window.)location、(window.)document。 + overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); + descsCache.add(key); // 必须:子类属性覆盖父类属性 } - } - }); - const hostEventKeys = new Set(); - const hostWindowPrototype = Object.getPrototypeOf(hostWindow); - if (hostWindowPrototype) { + }); + }; + + const collectHostWindowPrototypeDescriptors = () => { + const hostWindowPrototype = Object.getPrototypeOf(hostWindow); + if (!hostWindowPrototype) return; + getAllPropertyDescriptors(hostWindowPrototype, ([key, desc]) => { if (!desc || descsCache.has(key) || typeof key !== "string") return; if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { - eventDescs[key] = desc; + eventKeys.add(key); hostEventKeys.add(key); return; } - const tracked = { - descriptor: desc, - receiver: hostWindow, - isConstructor: isConstructorOrInterface(desc.value), - }; - if (desc.writable) { if (shouldFnBind(desc.value)) { - overridedDescs[key] = materializeDescriptor(tracked); + overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); descsCache.add(key); } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(tracked); + protoBaseDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); } } else if (desc.get || desc.set) { - overridedDescs[key] = materializeDescriptor(tracked); + overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); descsCache.add(key); } }); - } - - descsCache.clear(); // 内存释放 + }; - getAllPropertyDescriptors(hostWindow, ([key, desc]) => { - if ( - typeof key !== "string" || - hostEventKeys.has(key) || - !key.startsWith("on") || - !desc.configurable || - !desc.get || - !desc.set - ) { - return; - } - eventDescs[key] = desc; - hostEventKeys.add(key); - }); + const collectHostWindowEventDescriptors = () => { + getAllPropertyDescriptors(hostWindow, ([key, desc]) => { + if ( + typeof key !== "string" || + hostEventKeys.has(key) || + !key.startsWith("on") || + !desc.configurable || + !desc.get || + !desc.set + ) { + return; + } + eventKeys.add(key); + }); + }; const addHostWindowForwarding = (key: string) => { if (!(key in hostWindow)) return; const hostDescriptor = getHostWindowDescriptor(key); - if (hostWindowAccessors.includes(key)) { - overridedDescs[key] = { + if (hostWindowAccessors.has(key)) { + overriddenDescs[key] = { configurable: true, enumerable: true, get: () => Reflect.get(hostWindow, key, hostWindow), @@ -402,16 +388,16 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } : {}), }; - } else if (hostWindowMethods.includes(key)) { + } else if (hostWindowMethods.has(key)) { const method = Reflect.get(hostWindow, key, hostWindow); - overridedDescs[key] = { + overriddenDescs[key] = { configurable: true, enumerable: true, writable: true, value: typeof method === "function" ? Function.prototype.bind.call(method, hostWindow) : method, }; - } else { - overridedDescs[key] = { + } else if (hostWindowConstructors.has(key)) { + overriddenDescs[key] = { configurable: true, enumerable: true, writable: true, @@ -420,6 +406,11 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } }; + collectRealmDescriptors(); + collectHostWindowPrototypeDescriptors(); + descsCache.clear(); // 内存释放 + collectHostWindowEventDescriptors(); + for (const key of hostWindowKeys) addHostWindowForwarding(key); // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor @@ -457,14 +448,14 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), ...initOwnDescs, - ...overridedDescs, + ...overriddenDescs, }) : Object.create(Object.getPrototypeOf(realmGlobal), { ...initOwnDescs, - ...overridedDescs, + ...overriddenDescs, }); - return { sharedInitCopy, eventDescs }; + return { sharedInitCopy, eventKeys }; }; const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); @@ -485,7 +476,7 @@ export const createProxyContext = ( // let withContext: Context | undefined | { [key: string]: any } = undefined; // 为避免做成混乱。 ScriptCat脚本中 self, globalThis, parent 为固定值不能修改 - const { sharedInitCopy, eventDescs } = + const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); @@ -539,7 +530,7 @@ export const createProxyContext = ( }; }; - for (const key of Object.keys(eventDescs)) { + for (const key of eventKeys) { const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], From 33c4a3886092cda4552e9ef3748002e85ae82afd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:03:08 +0900 Subject: [PATCH 06/23] =?UTF-8?q?=F0=9F=93=9D=20=E8=A3=9C=E5=9B=9E=20sandb?= =?UTF-8?q?ox=20context=20=E7=B6=AD=E8=AD=B7=E8=A8=BB=E9=87=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 6a9454bf2..424ad80a1 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -173,6 +173,7 @@ const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: Descr } }; +// constructor/interface 不可绑定,否则 bind 会丢失 prototype 和静态成员。 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const isConstructorOrInterface = (value: unknown): value is Function => typeof value === "function" && ("prototype" in value || /^[A-Z]/.test(String(Reflect.get(value, "name")))); @@ -284,10 +285,13 @@ export type RealmRoots = { }; const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { + // descsCache 记录已处理的属性;先处理的 realm/子类 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(); + // hostEventKeys 用于区分 host 原型事件,避免第二次遍历 hostWindow 时重复处理。 const hostEventKeys = new Set(); const protoBaseDescs: DescriptorMap = Object.create(null); @@ -308,7 +312,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (!desc || descsCache.has(key) || typeof key !== "string") return; if (desc.writable) { - // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性。 + // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性;constructor/interface 则保留原值。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); descsCache.add(key); // 必须:子类属性覆盖父类属性 @@ -406,7 +410,10 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } }; + // 第一趟 realmGlobal:Firefox 的 content / USER_SCRIPT world 在这里保留完整的 JavaScript 内置对象。 collectRealmDescriptors(); + // 第二趟 hostWindow 原型链:补齐 Xray window 无法取得的 DOM/EventTarget 成员。 + // 一般的 hostWindow own properties 不应带入沙盒,只有事件属性和下面的白名单会被转发。 collectHostWindowPrototypeDescriptors(); descsCache.clear(); // 内存释放 collectHostWindowEventDescriptors(); @@ -418,6 +425,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // + 覆盖定义 (document, location, setTimeout, setInterval, addEventListener 等) // sharedInitCopy: ScriptCat脚本共通使用 + // PseudoWindow 没有真实 Window.prototype,因此祖先成员必须先手动复制到 sandbox own descriptors。 const USE_PSEUDO_WINDOW = true; // 日后或能设置使 ScriptCat的沙盒 window 能以 name / id 存取页面元素 class PseudoWindow {} @@ -538,6 +546,7 @@ export const createProxyContext = ( }; } + // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) { ownDescs[key] = { configurable: true, From f5b0587a344a06632fa9259c4e917ee5d61686d8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:26:25 +0900 Subject: [PATCH 07/23] =?UTF-8?q?=E2=9C=85=20=E8=A3=9C=E9=BD=8A=20Firefox?= =?UTF-8?q?=20sandbox=20realm=20regression=20=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 125 +++++++++++++++++- src/app/service/content/create_context.ts | 29 +++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 26f6cffda..b6a9e1461 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -37,6 +37,10 @@ const createSplitRealmRoots = (): RealmRoots => { const hostWindow = Object.create(null) as Record; const eventTarget = new EventTarget(); + // Firefox USER_SCRIPT 的 realm global 會以 host window 作為原型;測試必須保留這個拓撲, + // 才能確認 realm descriptor 收集不會意外把 host own properties 帶入 sandbox。 + Object.setPrototypeOf(realmGlobal, hostWindow); + realmGlobal.Node = class RealmNode {}; realmGlobal.XMLHttpRequest = class RealmXMLHttpRequest {}; realmGlobal.realmOnly = "realm-value"; @@ -52,6 +56,7 @@ const createSplitRealmRoots = (): RealmRoots => { hostWindow.EventTarget = EventTarget; hostWindow.Node = Node; hostWindow.NodeFilter = NodeFilter; + hostWindow.HTMLBodyElement = class HostHTMLBodyElement {}; hostWindow.Event = Event; hostWindow.XMLHttpRequest = class HostXMLHttpRequest { static DONE = 4; @@ -394,12 +399,59 @@ describe.concurrent("createProxyContext", () => { }); 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("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); @@ -429,7 +481,11 @@ describe.concurrent("createProxyContext", () => { }); it.concurrent("keeps all self-referential window names inside the sandbox", () => { - const sandbox = createProxyContext(createTestContext([])); + 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); @@ -509,6 +565,28 @@ describe.concurrent("createProxyContext", () => { 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(); @@ -543,5 +621,50 @@ describe.concurrent("createProxyContext", () => { 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 424ad80a1..a7e35644d 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -237,6 +237,7 @@ const hostWindowConstructors = new Set([ "Node", "Element", "HTMLElement", + "HTMLBodyElement", "Document", "DocumentFragment", "ShadowRoot", @@ -306,9 +307,11 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }; const collectRealmDescriptors = () => { - // 包含物件本身及所有父类(不包含Object)的PropertyDescriptor。 - // 主要是找出哪些 function 值、setter/getter 需要替换 global window。 - getAllPropertyDescriptors(realmGlobal, ([key, desc]) => { + // 只读取 realmGlobal own descriptors,避免沿 Firefox 的 hostWindow 原型链混合两个 realm。 + // 主要是找出哪些 function 值、setter/getter 需要替换 host window。 + const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); + Reflect.ownKeys(descriptors).forEach((key) => { + const desc = descriptors[key as keyof typeof descriptors]; if (!desc || descsCache.has(key) || typeof key !== "string") return; if (desc.writable) { @@ -547,7 +550,7 @@ export const createProxyContext = ( } // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 - for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) { + for (const key of ["window", "self", "globalThis"]) { ownDescs[key] = { configurable: true, enumerable: true, @@ -556,6 +559,24 @@ export const createProxyContext = ( }, }; } + 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) { ownDescs.eval.value = undefined; From 5721a6b8316b3e00adf5e056d0dfb9e01a81c8b3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:30:23 +0900 Subject: [PATCH 08/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=B0=A1=E5=8C=96?= =?UTF-8?q?=20sandbox=20descriptor=20materialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 49 ++++++++++------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index a7e35644d..af47745f4 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -156,42 +156,37 @@ export const shouldFnBind = (f: any) => { // 取物件本身及所有父类(不包含Object)的PropertyDescriptor type DescriptorOwner = Record; -type DescriptorEntry = [string | symbol, PropertyDescriptor]; -type TrackedDescriptor = { - descriptor: PropertyDescriptor; - receiver: DescriptorOwner; - isConstructor: boolean; -}; type DescriptorMap = Record; -const getAllPropertyDescriptors = (obj: DescriptorOwner, callback: (value: DescriptorEntry) => void) => { +const getAllPropertyDescriptors = ( + obj: DescriptorOwner, + callback: (key: string | symbol, descriptor: PropertyDescriptor) => void +) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); - Reflect.ownKeys(descs).forEach((key) => callback([key, descs[key as keyof typeof descs]])); + Reflect.ownKeys(descs).forEach((key) => callback(key, descs[key as keyof typeof descs])); obj = Object.getPrototypeOf(obj); } }; // constructor/interface 不可绑定,否则 bind 会丢失 prototype 和静态成员。 -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const isConstructorOrInterface = (value: unknown): value is Function => - typeof value === "function" && ("prototype" in value || /^[A-Z]/.test(String(Reflect.get(value, "name")))); - -const trackDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): TrackedDescriptor => ({ - descriptor, - receiver, - isConstructor: isConstructorOrInterface(descriptor.value), -}); +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; +}; -const materializeDescriptor = ({ descriptor, receiver, isConstructor }: TrackedDescriptor): PropertyDescriptor => { +const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { if ("value" in descriptor) { - if (typeof descriptor.value !== "function" || isConstructor) return { ...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), @@ -317,10 +312,10 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.writable) { // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性;constructor/interface 则保留原值。 if (shouldFnBind(desc.value)) { - overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); + protoBaseDescs[key] = materializeDescriptor(desc, realmGlobal); } return; } @@ -330,7 +325,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn eventKeys.add(key); } else if (desc.get || desc.set) { // 替换 getter/setter 的 this 为实际的 global window,例如 (window.)location、(window.)document。 - overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, realmGlobal)); + overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } }); @@ -340,7 +335,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const hostWindowPrototype = Object.getPrototypeOf(hostWindow); if (!hostWindowPrototype) return; - getAllPropertyDescriptors(hostWindowPrototype, ([key, desc]) => { + getAllPropertyDescriptors(hostWindowPrototype, (key, desc) => { if (!desc || descsCache.has(key) || typeof key !== "string") return; if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { @@ -351,20 +346,20 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.writable) { if (shouldFnBind(desc.value)) { - overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); + overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); + protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } } else if (desc.get || desc.set) { - overriddenDescs[key] = materializeDescriptor(trackDescriptor(desc, hostWindow)); + overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); } }); }; const collectHostWindowEventDescriptors = () => { - getAllPropertyDescriptors(hostWindow, ([key, desc]) => { + getAllPropertyDescriptors(hostWindow, (key, desc) => { if ( typeof key !== "string" || hostEventKeys.has(key) || From a4fc4efc713ae88dad718b47277d2191438ff705 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:12:47 +0900 Subject: [PATCH 09/23] Update create_context.ts --- src/app/service/content/create_context.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index af47745f4..55fd5e6ad 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -165,7 +165,9 @@ const getAllPropertyDescriptors = ( ) => { while (obj && obj !== Object) { const descs = Object.getOwnPropertyDescriptors(obj); - Reflect.ownKeys(descs).forEach((key) => callback(key, descs[key as keyof typeof descs])); + for (const key of Reflect.ownKeys(descs)) { + callback(key, descs[key as keyof typeof descs]); + } obj = Object.getPrototypeOf(obj); } }; @@ -305,9 +307,9 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 只读取 realmGlobal own descriptors,避免沿 Firefox 的 hostWindow 原型链混合两个 realm。 // 主要是找出哪些 function 值、setter/getter 需要替换 host window。 const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - Reflect.ownKeys(descriptors).forEach((key) => { + for (const key of Reflect.ownKeys(descriptors)) { const desc = descriptors[key as keyof typeof descriptors]; - if (!desc || descsCache.has(key) || typeof key !== "string") return; + if (!desc || descsCache.has(key) || typeof key !== "string") continue; if (desc.writable) { // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性;constructor/interface 则保留原值。 @@ -317,7 +319,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, realmGlobal); } - return; + continue; } if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { @@ -328,7 +330,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } - }); + } }; const collectHostWindowPrototypeDescriptors = () => { From 8e02a9d0c004fda3c81cc275071df766bcd040c7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:03:53 +0900 Subject: [PATCH 10/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=B0=A1=E5=8C=96?= =?UTF-8?q?=20sandbox=20descriptor=20=E6=94=B6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 31 +++++++---------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 55fd5e6ad..cc141563b 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -283,14 +283,12 @@ export type RealmRoots = { }; const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { - // descsCache 记录已处理的属性;先处理的 realm/子类 descriptor 不会被后续父类覆盖。 + // 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(); - // hostEventKeys 用于区分 host 原型事件,避免第二次遍历 hostWindow 时重复处理。 - const hostEventKeys = new Set(); const protoBaseDescs: DescriptorMap = Object.create(null); const getHostWindowDescriptor = (key: string): PropertyDescriptor | undefined => { @@ -304,29 +302,26 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }; const collectRealmDescriptors = () => { - // 只读取 realmGlobal own descriptors,避免沿 Firefox 的 hostWindow 原型链混合两个 realm。 - // 主要是找出哪些 function 值、setter/getter 需要替换 host window。 + // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); for (const key of Reflect.ownKeys(descriptors)) { const desc = descriptors[key as keyof typeof descriptors]; if (!desc || descsCache.has(key) || typeof key !== "string") continue; if (desc.writable) { - // 替换 function 的 this 为实际的 global window。被封装的属性会继续向父类寻找原生属性;constructor/interface 则保留原值。 + // 原生 function 绑定到所属 root;constructor/interface 保留原值。 if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { - protoBaseDescs[key] = materializeDescriptor(desc, realmGlobal); } continue; } if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { - // 替换 onxxxxx 事件赋值操作,例如 (window.)onload、(window.)onerror。 + // 替换 onxxxxx 事件赋值操作。 eventKeys.add(key); } else if (desc.get || desc.set) { - // 替换 getter/setter 的 this 为实际的 global window,例如 (window.)location、(window.)document。 + // 替换 getter/setter 的 this 为实际的 global window。 overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } @@ -342,7 +337,6 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { eventKeys.add(key); - hostEventKeys.add(key); return; } @@ -362,14 +356,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const collectHostWindowEventDescriptors = () => { getAllPropertyDescriptors(hostWindow, (key, desc) => { - if ( - typeof key !== "string" || - hostEventKeys.has(key) || - !key.startsWith("on") || - !desc.configurable || - !desc.get || - !desc.set - ) { + if (typeof key !== "string" || !key.startsWith("on") || !desc.configurable || !desc.get || !desc.set) { return; } eventKeys.add(key); @@ -410,10 +397,10 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } }; - // 第一趟 realmGlobal:Firefox 的 content / USER_SCRIPT world 在这里保留完整的 JavaScript 内置对象。 + // 第一趟 realmGlobal:保留 JavaScript 内置对象。 collectRealmDescriptors(); - // 第二趟 hostWindow 原型链:补齐 Xray window 无法取得的 DOM/EventTarget 成员。 - // 一般的 hostWindow own properties 不应带入沙盒,只有事件属性和下面的白名单会被转发。 + // 第二趟 hostWindow 原型链:补齐 Xray 截断的 DOM/EventTarget 成员。 + // hostWindow own properties 仅由事件属性和白名单转发。 collectHostWindowPrototypeDescriptors(); descsCache.clear(); // 内存释放 collectHostWindowEventDescriptors(); From b6ba024cf2d5511b1f83458a28e03723af9c19fa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:24:49 +0900 Subject: [PATCH 11/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=20realm=20=E4=B8=8E=20host=20descriptor=20=E6=94=B6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 61 ++++--- src/app/service/content/create_context.ts | 149 +----------------- 2 files changed, 48 insertions(+), 162 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index b6a9e1461..d097046bd 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -35,14 +35,13 @@ const createTestContext = (grants: string[], metadata: Record 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 作為原型;測試必須保留這個拓撲, - // 才能確認 realm descriptor 收集不會意外把 host own properties 帶入 sandbox。 + // Firefox USER_SCRIPT 的 realm global 會以 host window 作為原型。 Object.setPrototypeOf(realmGlobal, hostWindow); + Object.setPrototypeOf(hostWindow, hostWindowPrototype); - realmGlobal.Node = class RealmNode {}; - realmGlobal.XMLHttpRequest = class RealmXMLHttpRequest {}; realmGlobal.realmOnly = "realm-value"; Object.defineProperty(realmGlobal, "realmAccessor", { configurable: true, @@ -62,10 +61,33 @@ const createSplitRealmRoots = (): RealmRoots => { static DONE = 4; }; hostWindow.document = document; - hostWindow.hostOnly = "host-value"; 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, @@ -426,6 +448,20 @@ describe.concurrent("createProxyContext", () => { 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); @@ -458,7 +494,6 @@ describe.concurrent("createProxyContext", () => { expect(sandbox.document).toBe(document); expect(sandbox.realmOnly).toBe("realm-value"); expect(sandbox.realmAccessor).toBe("realm-receiver"); - expect(sandbox.hostOnly).toBeUndefined(); const listener = vi.fn(); sandbox.addEventListener("split-root", listener); @@ -495,20 +530,6 @@ describe.concurrent("createProxyContext", () => { expect(sandbox.frames).toBe(sandbox); }); - it.concurrent("forwards live host accessors without leaking page globals", () => { - const sandbox = createProxyContext(createTestContext([])); - const pageKey = "__scriptcat_split_global_page_value"; - - Reflect.set(window, pageKey, "page-value"); - try { - expect(sandbox.document).toBe(window.document); - expect(sandbox.location).toBe(window.location); - expect(sandbox[pageKey]).toBeUndefined(); - } finally { - Reflect.deleteProperty(window, pageKey); - } - }); - it.concurrent("preserves host constructor static constants and prototype identity", () => { const sandbox = createProxyContext(createTestContext([])); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index cc141563b..d30ed02e2 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -196,82 +196,6 @@ const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: Descrip }; }; -// Firefox 的 content / USER_SCRIPT world 将 JavaScript global 与页面 window 拆成两个 realm。 -// 这里只读取 hostWindow 的原型链,并转发确定需要页面 brand 的成员,避免把页面全局 own properties 带入沙盒。 -const hostWindowAccessors = new Set([ - "document", - "location", - "navigator", - "history", - "screen", - "performance", - "crypto", - "localStorage", - "sessionStorage", - "visualViewport", - "innerWidth", - "innerHeight", - "scrollX", - "scrollY", - "devicePixelRatio", -]); -const hostWindowMethods = new Set([ - "addEventListener", - "removeEventListener", - "dispatchEvent", - "getComputedStyle", - "matchMedia", - "requestAnimationFrame", - "cancelAnimationFrame", - "scroll", - "scrollTo", - "scrollBy", - "blur", -]); -const hostWindowConstructors = new Set([ - "Window", - "EventTarget", - "Node", - "Element", - "HTMLElement", - "HTMLBodyElement", - "Document", - "DocumentFragment", - "ShadowRoot", - "Text", - "Range", - "MutationObserver", - "NodeFilter", - "TreeWalker", - "Event", - "CustomEvent", - "MouseEvent", - "KeyboardEvent", - "PointerEvent", - "InputEvent", - "FocusEvent", - "ErrorEvent", - "ProgressEvent", - "MessageEvent", - "StorageEvent", - "WheelEvent", - "DragEvent", - "ClipboardEvent", - "DOMParser", - "XMLSerializer", - "FormData", - "File", - "FileList", - "Blob", - "URL", - "URLSearchParams", - "Headers", - "Request", - "Response", - "XMLHttpRequest", -]); -const hostWindowKeys = new Set([...hostWindowAccessors, ...hostWindowMethods, ...hostWindowConstructors]); - type GlobalSnapshot = { sharedInitCopy: typeof globalThis & Record; eventKeys: Set; @@ -291,16 +215,6 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const eventKeys = new Set(); const protoBaseDescs: DescriptorMap = Object.create(null); - const getHostWindowDescriptor = (key: string): PropertyDescriptor | undefined => { - let owner: DescriptorOwner | null = hostWindow; - while (owner) { - const descriptor = Object.getOwnPropertyDescriptor(owner, key); - if (descriptor) return descriptor; - owner = Object.getPrototypeOf(owner); - } - return undefined; - }; - const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); @@ -328,19 +242,17 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } }; - const collectHostWindowPrototypeDescriptors = () => { - const hostWindowPrototype = Object.getPrototypeOf(hostWindow); - if (!hostWindowPrototype) return; - - getAllPropertyDescriptors(hostWindowPrototype, (key, desc) => { - if (!desc || descsCache.has(key) || typeof key !== "string") return; + const collectHostWindowDescriptors = () => { + getAllPropertyDescriptors(hostWindow, (key, desc) => { + if (!desc || typeof key !== "string") return; if (desc.configurable && desc.get && desc.set && key.startsWith("on")) { eventKeys.add(key); return; } + if (descsCache.has(key)) return; - if (desc.writable) { + if ("value" in desc) { if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); @@ -354,58 +266,11 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); }; - const collectHostWindowEventDescriptors = () => { - getAllPropertyDescriptors(hostWindow, (key, desc) => { - if (typeof key !== "string" || !key.startsWith("on") || !desc.configurable || !desc.get || !desc.set) { - return; - } - eventKeys.add(key); - }); - }; - - const addHostWindowForwarding = (key: string) => { - if (!(key in hostWindow)) return; - const hostDescriptor = getHostWindowDescriptor(key); - if (hostWindowAccessors.has(key)) { - overriddenDescs[key] = { - configurable: true, - enumerable: true, - get: () => Reflect.get(hostWindow, key, hostWindow), - ...(hostDescriptor?.set - ? { - set: (value: any) => { - Reflect.set(hostWindow, key, value, hostWindow); - }, - } - : {}), - }; - } else if (hostWindowMethods.has(key)) { - const method = Reflect.get(hostWindow, key, hostWindow); - overriddenDescs[key] = { - configurable: true, - enumerable: true, - writable: true, - value: typeof method === "function" ? Function.prototype.bind.call(method, hostWindow) : method, - }; - } else if (hostWindowConstructors.has(key)) { - overriddenDescs[key] = { - configurable: true, - enumerable: true, - writable: true, - value: Reflect.get(hostWindow, key, hostWindow), - }; - } - }; - // 第一趟 realmGlobal:保留 JavaScript 内置对象。 collectRealmDescriptors(); - // 第二趟 hostWindow 原型链:补齐 Xray 截断的 DOM/EventTarget 成员。 - // hostWindow own properties 仅由事件属性和白名单转发。 - collectHostWindowPrototypeDescriptors(); + // 第二趟 hostWindow:补齐 Firefox split-realm 的 host 成员。 + collectHostWindowDescriptors(); descsCache.clear(); // 内存释放 - collectHostWindowEventDescriptors(); - - for (const key of hostWindowKeys) addHostWindowForwarding(key); // sharedInitCopy: 完全继承Window.prototype 及 自定义 OwnPropertyDescriptor // OwnPropertyDescriptor定义 为 原OwnPropertyDescriptor定义 (DragEvent, MouseEvent, RegExp, EventTarget, JSON等) From c9cb643084a035c19661f150a2d50881df8f7fad Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:02:09 +0900 Subject: [PATCH 12/23] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E7=B0=A1=E5=8C=96?= =?UTF-8?q?=20realm=20=E8=88=87=20host=20descriptor=20=E6=94=B6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index d30ed02e2..d2fb32c54 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -218,13 +218,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Reflect.ownKeys(descriptors)) { - const desc = descriptors[key as keyof typeof descriptors]; - if (!desc || descsCache.has(key) || typeof key !== "string") continue; + for (const key of Object.keys(descriptors)) { + const desc = descriptors[key]; + if (descsCache.has(key)) continue; - if (desc.writable) { + if ("value" in desc) { // 原生 function 绑定到所属 root;constructor/interface 保留原值。 - if (shouldFnBind(desc.value)) { + if (desc.writable && shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } @@ -234,7 +234,9 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 eventKeys.add(key); - } else if (desc.get || desc.set) { + continue; + } + if (desc.get || desc.set) { // 替换 getter/setter 的 this 为实际的 global window。 overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 @@ -259,7 +261,9 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } - } else if (desc.get || desc.set) { + return; + } + if (desc.get || desc.set) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); } From df3a4a58e911a428e8eaf381b825834c1304e8bc Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:47:39 +0900 Subject: [PATCH 13/23] =?UTF-8?q?=E2=9C=85=20=E8=A3=9C=E5=BC=B7=20Chrome?= =?UTF-8?q?=20descriptor=20=E7=9B=B8=E5=AE=B9=E6=80=A7=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index d097046bd..8be6e1eaa 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -462,6 +462,47 @@ describe.concurrent("createProxyContext", () => { expect(sandbox.dynamicHostAccessor).toBe("updated-value"); }); + it.concurrent("keeps Chrome's shared global/window prototype descriptors", () => { + const prototype = Object.create(null); + const prototypeValue = { source: "prototype" }; + prototype.chromePrototypeMethod = { + chromePrototypeMethod(this: unknown) { + return this; + }, + }.chromePrototypeMethod; + prototype.chromePrototypeValue = prototypeValue; + let accessorValue = "initial"; + Object.defineProperty(prototype, "chromePrototypeAccessor", { + configurable: true, + enumerable: true, + get() { + return this === chromeWindow ? accessorValue : "wrong-receiver"; + }, + set(value: string) { + accessorValue = this === chromeWindow ? value : "wrong-receiver"; + }, + }); + + const eventTarget = new EventTarget(); + const chromeWindow = Object.assign(Object.create(prototype), { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + }); + const sandbox = createProxyContext(createTestContext([]), { + realmGlobal: chromeWindow, + hostWindow: chromeWindow, + }); + + expect(sandbox.chromePrototypeMethod()).toBe(chromeWindow); + expect(sandbox.chromePrototypeValue).toBe(prototypeValue); + expect(sandbox.chromePrototypeAccessor).toBe("initial"); + sandbox.chromePrototypeAccessor = "updated"; + expect(sandbox.chromePrototypeAccessor).toBe("updated"); + expect(sandbox.window).toBe(sandbox); + expect(sandbox.self).toBe(sandbox); + expect(sandbox.globalThis).toBe(sandbox); + }); + it.concurrent("preserves non-self top, parent, and frames references from an iframe realm", () => { const roots = createSplitRealmRoots(); const parentWindow = Object.create(null); From 829c70cc917c6da6e806fa985b08271bc2bd06f9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:25:57 +0900 Subject: [PATCH 14/23] =?UTF-8?q?=E2=9C=85=20=E8=A3=9C=E5=BC=B7=20pseudo?= =?UTF-8?q?=20window=20host=20identity=20=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 8be6e1eaa..c381d52ae 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -581,6 +581,17 @@ describe.concurrent("createProxyContext", () => { expect(sandbox.Event.prototype).toBe(window.Event.prototype); }); + it.concurrent("uses the host window identity for pseudo-window descriptors", () => { + const roots = createSplitRealmRoots(); + roots.hostWindow[Symbol.toStringTag] = "Window"; + const sandbox = createProxyContext(createTestContext([]), roots); + + expect(Object.prototype.toString.call(sandbox)).toBe("[object Window]"); + expect(sandbox.constructor).toBe(roots.hostWindow.constructor); + expect(sandbox.__proto__).toBe(roots.hostWindow.__proto__); + expect(Object.getPrototypeOf(sandbox)).toBeNull(); + }); + it.concurrent("keeps JavaScript built-in static methods available", () => { const sandbox = createProxyContext(createTestContext([])); From 0b5975efd4b9f7668bf15139d4999c54c9550486 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:32:03 +0900 Subject: [PATCH 15/23] `const bindFn = Function.prototype.bind;` --- src/app/service/content/create_context.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index d2fb32c54..0e46edb92 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -180,12 +180,15 @@ const isConstructorOrInterface = (value: unknown) => { 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: Function.prototype.bind.call(descriptor.value, receiver), + value: bindFn.call(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; From 597464d0c42cc963775d7e36129f2f0c960fdc92 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:39:15 +0900 Subject: [PATCH 16/23] =?UTF-8?q?=F0=9F=93=9D=20=E8=A3=9C=E5=9B=9E=20sandb?= =?UTF-8?q?ox=20descriptor=20=E8=A8=BB=E9=87=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/create_context.ts | 26 +++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 0e46edb92..9804ef672 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -210,12 +210,21 @@ export type RealmRoots = { }; const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { - // descsCache 记录已处理的属性;先处理的 descriptor 覆盖后续父类。 + // 在 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 会以 sandbox own descriptor 覆盖原有定义;eventKeys 只记录需要模拟的 on* 属性。 + + // 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 = () => { @@ -226,7 +235,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (descsCache.has(key)) continue; if ("value" in desc) { - // 原生 function 绑定到所属 root;constructor/interface 保留原值。 + // 替换 function 的 this 为实际的 realm global。 if (desc.writable && shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 @@ -236,11 +245,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (desc.configurable && desc.get && desc.set && desc.enumerable && key.startsWith("on")) { // 替换 onxxxxx 事件赋值操作。 + // 例:(window.)onload, (window.)onerror。 eventKeys.add(key); continue; } if (desc.get || desc.set) { - // 替换 getter/setter 的 this 为实际的 global window。 + // 替换 getter setter 的 this 为实际的 realm global。 + // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); descsCache.add(key); // 必须:子类属性覆盖父类属性 } @@ -248,16 +259,21 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }; 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); @@ -267,6 +283,8 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn return; } if (desc.get || desc.set) { + // 替换 getter setter 的 this 为实际的 host window。 + // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); } From c33bd49c9b9904323bf77b481b889481f3d9803f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:52:56 +0900 Subject: [PATCH 17/23] =?UTF-8?q?=E2=9C=85=20=E8=A3=9C=E5=BC=B7=20split-re?= =?UTF-8?q?alm=20descriptor=20regression=20=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index c381d52ae..0aeab8b91 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -64,6 +64,7 @@ const createSplitRealmRoots = (): RealmRoots => { hostWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget); hostWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); hostWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); + hostWindow.dynamicHostValue = { source: "host" }; hostWindow.dynamicHostMethod = { dynamicHostMethod(this: unknown) { return this; @@ -88,6 +89,12 @@ const createSplitRealmRoots = (): RealmRoots => { return this; }, }.dynamicPrototypeMethod; + Object.defineProperty(hostWindowPrototype, "dynamicPrototypeValue", { + configurable: true, + enumerable: true, + value: { source: "prototype" }, + writable: false, + }); Object.defineProperty(hostWindow, "onload", { configurable: true, enumerable: true, @@ -453,8 +460,16 @@ describe.concurrent("createProxyContext", () => { const sandbox = createProxyContext(createTestContext([]), roots); expect(sandbox.dynamicHostMethod()).toBe(roots.hostWindow); + expect(sandbox.dynamicHostValue).toBe(roots.hostWindow.dynamicHostValue); expect(sandbox.dynamicHostAccessor).toBe("host-value"); expect(sandbox.dynamicPrototypeMethod()).toBe(roots.hostWindow); + expect(sandbox.dynamicPrototypeValue).toBe(roots.hostWindow.dynamicPrototypeValue); + expect(Object.getOwnPropertyDescriptor(sandbox, "dynamicPrototypeValue")).toMatchObject({ + value: roots.hostWindow.dynamicPrototypeValue, + writable: false, + enumerable: true, + configurable: true, + }); expect(sandbox.DynamicInterface.staticValue).toBe("static-value"); expect(sandbox.DynamicInterface.prototype).toBe(roots.hostWindow.DynamicInterface.prototype); @@ -462,6 +477,33 @@ describe.concurrent("createProxyContext", () => { expect(sandbox.dynamicHostAccessor).toBe("updated-value"); }); + it.concurrent("keeps host events when a realm accessor uses the same property name", () => { + const roots = createSplitRealmRoots(); + Object.defineProperty(roots.realmGlobal, "oncollectorcollision", { + configurable: false, + enumerable: true, + get() { + return "realm-accessor"; + }, + }); + Object.defineProperty(roots.hostWindow, "oncollectorcollision", { + configurable: true, + enumerable: true, + get: () => null, + set: () => undefined, + }); + + const sandbox = createProxyContext(createTestContext([]), roots); + const handler = vi.fn(); + + sandbox.oncollectorcollision = handler; + expect(sandbox.oncollectorcollision).toBe(handler); + roots.hostWindow.dispatchEvent(new Event("collectorcollision")); + sandbox.oncollectorcollision = null; + + expect(handler).toHaveBeenCalledTimes(1); + }); + it.concurrent("keeps Chrome's shared global/window prototype descriptors", () => { const prototype = Object.create(null); const prototypeValue = { source: "prototype" }; From 3ba398a6fe9238095f6831c310993cb612492a70 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:37:32 +0900 Subject: [PATCH 18/23] update vitests --- .../service/content/create_context.test.ts | 1379 +++++++++-------- src/app/service/content/create_context.ts | 7 +- 2 files changed, 777 insertions(+), 609 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 0aeab8b91..b242ba73d 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -1,47 +1,111 @@ -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, type RealmRoots } from "./create_context"; -const createScriptInfo = (metadata: Record = {}): TScriptInfo => - ({ - id: 1, - uuid: "script-uuid", - name: "create-context-test", - metadata: { - grant: ["none"], - version: ["1.0.0"], - ...metadata, +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); }, - code: "", - sourceCode: "", - value: { - foo: "bar", - nested: { a: 1 }, + removeEventListener(type: string, listener: any) { + return eventTarget.removeEventListener(type, listener); }, - resource: {}, - }) as unknown as TScriptInfo; - -const createTestContext = (grants: string[], metadata: Record = {}) => - createContext( - createScriptInfo(metadata), - { script: { name: "create-context-test" }, scriptMetaStr: "" }, - "vitest", - undefined as any, - undefined as any, - 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); + 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, @@ -51,50 +115,62 @@ const createSplitRealmRoots = (): RealmRoots => { }, }); - 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; + const TestEvent = class TestEvent { + type: string; + + constructor(type: string) { + this.type = type; + } }; - hostWindow.document = document; - hostWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget); - hostWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); - hostWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); - hostWindow.dynamicHostValue = { source: "host" }; - hostWindow.dynamicHostMethod = { + 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 dynamicHostValue = "host-value"; - Object.defineProperty(hostWindow, "dynamicHostAccessor", { + + let dynamicHostAccessorValue = "host-value"; + Object.defineProperty(hostWindowPrototype, "dynamicHostAccessor", { configurable: true, enumerable: true, get() { - return this === hostWindow ? dynamicHostValue : "wrong-receiver"; + return this === hostWindow ? dynamicHostAccessorValue : "wrong-receiver"; }, set(value) { - dynamicHostValue = value; + dynamicHostAccessorValue = this === hostWindow ? value : "wrong-receiver"; }, }); - const DynamicInterface = function DynamicInterface() {}; - (DynamicInterface as any).staticValue = "static-value"; - hostWindow.DynamicInterface = DynamicInterface; - hostWindowPrototype.dynamicPrototypeMethod = { - dynamicPrototypeMethod(this: unknown) { - return this; - }, - }.dynamicPrototypeMethod; - Object.defineProperty(hostWindowPrototype, "dynamicPrototypeValue", { + hostWindowPrototype.hostNativeCallable = Object.prototype.valueOf; + + Object.defineProperty(hostWindowPrototype, "location", { configurable: true, enumerable: true, - value: { source: "prototype" }, - writable: false, + 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, @@ -107,92 +183,170 @@ const createSplitRealmRoots = (): RealmRoots => { 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; - return { realmGlobal, hostWindow }; + const roots: RealmRoots = { realmGlobal, hostWindow }; + return { roots, eventTarget, hostWindow, hostWindowPrototype, realmGlobal, TestEvent }; }; -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); +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 => + ({ + id: 1, + uuid: "script-uuid", + name: "create-context-test", + metadata: { + grant: ["none"], + version: ["1.0.0"], + ...metadata, + }, + code: "", + sourceCode: "", + value: { + foo: "bar", + nested: { a: 1 }, + }, + resource: {}, + }) as unknown as TScriptInfo; + +const createTestContext = (grants: string[], metadata: Record = {}) => + createContext( + createScriptInfo(metadata), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set(grants) + ); + +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"], @@ -205,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, @@ -233,553 +386,569 @@ 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("不傳 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(); + + 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(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("default roots 下 constructor/interface 仍保留 prototype 與 static member", async () => { + const fixture = createDefaultRootWindow(); + vi.stubGlobal("window", fixture.hostWindow); + 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(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.concurrent("隔离沙盒全局对象、保护内部字段,并提供一次性的 $ 入口", () => { - const context = createTestContext(["GM_getValue"]); - const sandbox = createProxyContext(context); + 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"); + 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(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); - 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.concurrent("原生函数会绑定到真实 global,避免作为裸函数调用时报 Illegal invocation", () => { - 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(); - }); - - 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) { + }); + + 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 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"); }); - sandbox.onload = onload; - expect(addEventListener).toHaveBeenCalledWith("load", expect.any(Object)); + expect(context.onurlchange).toBeNull(); + sandbox.onurlchange = handler; + fixture.location.href = "https://example.test/next"; + fixture.navigation.dispatchEvent(new Event("navigate")); + await Promise.resolve(); - const eventObject = addEventListener.mock.calls.find(([name]) => name === "load")?.[1] as EventListenerObject; - eventObject.handleEvent(new Event("load")); - expect(onload).toHaveBeenCalledTimes(1); + 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(); - sandbox.onload = null; - expect(removeEventListener).toHaveBeenCalledWith("load", eventObject); + 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.concurrent("TM半沙盒:把祖先类别继承直接写在半沙盒上 ( #1462 #1463 )", () => { - const sandbox = createProxyContext(createTestContext([])); - expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); +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); }); - // 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("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, }); - - 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); + 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"; + }, }); - - 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); + 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"); + }, }); - 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); - }); + 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"); }); - 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); + it("Chrome shared-root 下仍保留 host prototype、內建 static 與 alias 語義", () => { + const fixture = createSharedRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - const getValue = Reflect.get(sandbox.window, "GM_getValue") as (key: string) => unknown; - expect(getValue("foo")).toBe("bar"); + 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"); - Reflect.set(sandbox.self, "__split_global_alias_value", "sandbox-value"); + sandbox.dynamicHostAccessor = "updated"; + expect(sandbox.dynamicHostAccessor).toBe("updated"); + expect(sandbox.window).toBe(sandbox); + expect(sandbox.self).toBe(sandbox); + expect(sandbox.globalThis).toBe(sandbox); + }); - 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("收集 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.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("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, }); - 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.dynamicHostValue).toBe(roots.hostWindow.dynamicHostValue); - expect(sandbox.dynamicHostAccessor).toBe("host-value"); - expect(sandbox.dynamicPrototypeMethod()).toBe(roots.hostWindow); - expect(sandbox.dynamicPrototypeValue).toBe(roots.hostWindow.dynamicPrototypeValue); - expect(Object.getOwnPropertyDescriptor(sandbox, "dynamicPrototypeValue")).toMatchObject({ - value: roots.hostWindow.dynamicPrototypeValue, - writable: false, - enumerable: true, - configurable: true, - }); - expect(sandbox.DynamicInterface.staticValue).toBe("static-value"); - expect(sandbox.DynamicInterface.prototype).toBe(roots.hostWindow.DynamicInterface.prototype); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - sandbox.dynamicHostAccessor = "updated-value"; - expect(sandbox.dynamicHostAccessor).toBe("updated-value"); - }); + expect(sandbox.hostOnlySecret).toBe(fixture.hostWindow.hostOnlySecret); + expect(sandbox.hostOnlyHiddenSecret).toBe(fixture.hostWindow.hostOnlyHiddenSecret); + }); - it.concurrent("keeps host events when a realm accessor uses the same property name", () => { - const roots = createSplitRealmRoots(); - Object.defineProperty(roots.realmGlobal, "oncollectorcollision", { - configurable: false, - enumerable: true, - get() { - return "realm-accessor"; - }, - }); - Object.defineProperty(roots.hostWindow, "oncollectorcollision", { - configurable: true, - enumerable: true, - get: () => null, - set: () => undefined, - }); + it("保留 constructor/interface 的 prototype 與 static member,不把它們 bind 剝空", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - const sandbox = createProxyContext(createTestContext([]), roots); - const handler = vi.fn(); + 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); + }); - sandbox.oncollectorcollision = handler; - expect(sandbox.oncollectorcollision).toBe(handler); - roots.hostWindow.dispatchEvent(new Event("collectorcollision")); - sandbox.oncollectorcollision = null; + it("以 hostWindow identity 建立 pseudo-window 的 toStringTag/constructor/__proto__", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - expect(handler).toHaveBeenCalledTimes(1); - }); + 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.concurrent("keeps Chrome's shared global/window prototype descriptors", () => { - const prototype = Object.create(null); - const prototypeValue = { source: "prototype" }; - prototype.chromePrototypeMethod = { - chromePrototypeMethod(this: unknown) { - return this; - }, - }.chromePrototypeMethod; - prototype.chromePrototypeValue = prototypeValue; - let accessorValue = "initial"; - Object.defineProperty(prototype, "chromePrototypeAccessor", { - configurable: true, - enumerable: true, - get() { - return this === chromeWindow ? accessorValue : "wrong-receiver"; - }, - set(value: string) { - accessorValue = this === chromeWindow ? value : "wrong-receiver"; - }, - }); + 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; - const eventTarget = new EventTarget(); - const chromeWindow = Object.assign(Object.create(prototype), { - addEventListener: eventTarget.addEventListener.bind(eventTarget), - removeEventListener: eventTarget.removeEventListener.bind(eventTarget), - }); - const sandbox = createProxyContext(createTestContext([]), { - realmGlobal: chromeWindow, - hostWindow: chromeWindow, - }); + add(eventName, listener); + dispatch(new fixture.TestEvent(eventName)); + remove(eventName, listener); + dispatch(new fixture.TestEvent(eventName)); - expect(sandbox.chromePrototypeMethod()).toBe(chromeWindow); - expect(sandbox.chromePrototypeValue).toBe(prototypeValue); - expect(sandbox.chromePrototypeAccessor).toBe("initial"); - sandbox.chromePrototypeAccessor = "updated"; - expect(sandbox.chromePrototypeAccessor).toBe("updated"); - expect(sandbox.window).toBe(sandbox); - expect(sandbox.self).toBe(sandbox); - expect(sandbox.globalThis).toBe(sandbox); + 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); }); - 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; + sandbox.onmessage = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("message")); + sandbox.onmessage = null; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("message")); - const sandbox = createProxyContext(createTestContext([]), roots); + expect(handler).toHaveBeenCalledTimes(1); + expect(fixture.eventTarget.listenerCount("message")).toBe(0); + }); - expect(sandbox.parent).toBe(parentWindow); - expect(sandbox.top).toBe(topWindow); - expect(sandbox.frames).toBe(frames); + 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, }); - - 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); + 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"; + }, }); - - 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); + 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); - it.concurrent("preserves host constructor static constants and prototype identity", () => { - const sandbox = createProxyContext(createTestContext([])); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - 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); - }); + expect(sandbox.precedenceAccessor).toBe("host"); + expect(sandbox.hostAccessor).toBe("host"); + sandbox.hostAccessor = "updated"; + expect(hostValue).toBe("updated"); + }); - it.concurrent("uses the host window identity for pseudo-window descriptors", () => { - const roots = createSplitRealmRoots(); - roots.hostWindow[Symbol.toStringTag] = "Window"; - const sandbox = createProxyContext(createTestContext([]), roots); + 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"; + }; - expect(Object.prototype.toString.call(sandbox)).toBe("[object Window]"); - expect(sandbox.constructor).toBe(roots.hostWindow.constructor); - expect(sandbox.__proto__).toBe(roots.hostWindow.__proto__); - expect(Object.getPrototypeOf(sandbox)).toBeNull(); + // 模擬 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, }); - it.concurrent("keeps JavaScript built-in static methods available", () => { - const sandbox = createProxyContext(createTestContext([])); + 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"); + }); - expect(sandbox.Number.isNaN).toBe(Number.isNaN); - expect(sandbox.Math.max(2, 7)).toBe(7); - expect(sandbox.Object.isFrozen(Object.freeze({}))).toBe(true); + 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, }); - 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(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(); - add(eventName, listener); - dispatch(new Event(eventName)); - remove(eventName, listener); + sandbox.oncollectorcollision = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("collectorcollision")); + sandbox.oncollectorcollision = null; - expect(listener).toHaveBeenCalledTimes(1); - }); + expect(handler).toHaveBeenCalledTimes(1); + }); - it.concurrent("does not register an event listener for an object handler", () => { - const sandbox = createProxyContext(createTestContext([])); - const listenerObject = { handleEvent: vi.fn() }; + 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); + }); - Reflect.set(sandbox, "onfocus", listenerObject); - window.dispatchEvent(new Event("focus")); + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); - expect(listenerObject.handleEvent).not.toHaveBeenCalled(); - sandbox.onfocus = null; - }); + Reflect.set(sandbox.self, "__split_alias_value", "sandbox-value"); - it.concurrent("removes the old event listener when an on-property is cleared", () => { - const sandbox = createProxyContext(createTestContext([])); - const handler = vi.fn(); + 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(); + }); - sandbox.onresize = handler; - window.dispatchEvent(new Event("resize")); - sandbox.onresize = null; - window.dispatchEvent(new Event("resize")); + 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); + }); - expect(handler).toHaveBeenCalledTimes(1); + 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 }, }); - 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; - } - }); + 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.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("沒有 window.onurlchange grant 時不建立 accessor 或 host navigation channel", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(createTestContext([]), fixture.roots); + const handler = vi.fn(); - it.concurrent("isolates writes between split-global sandboxes", () => { - const first = createProxyContext(createTestContext([])); - const second = createProxyContext(createTestContext([])); + expect(sandbox.onurlchange).toBeUndefined(); + expect("onurlchange" in sandbox).toBe(false); - first.__split_global_local_value = "first"; + sandbox.onurlchange = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("urlchange")); - expect(first.__split_global_local_value).toBe("first"); - expect(second.__split_global_local_value).toBeUndefined(); - expect(Reflect.get(window, "__split_global_local_value")).toBeUndefined(); - }); + expect(handler).not.toHaveBeenCalled(); + expect(fixture.eventTarget.listenerCount("urlchange")).toBe(0); + }); - it.concurrent("keeps the page window identity separate from the sandbox identity", () => { - const sandbox = createProxyContext(createTestContext([])); + it("@grant window.onurlchange 使用獨立 accessor 與 host urlchange channel", () => { + const fixture = createSplitRealmRoots(); + const context = createTestContext(["window.onurlchange"]); - expect(sandbox).not.toBe(window); - expect(sandbox.unsafeWindow).toBe(window); + expect(context.onurlchange).toBeNull(); + const sandbox = createProxyContext(context, fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); }); - 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"); - } - ); + 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 9804ef672..a0f15204f 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -194,8 +194,8 @@ const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: Descrip if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get?.bind(receiver), - set: descriptor.set?.bind(receiver), + get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, + set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, }; }; @@ -233,12 +233,12 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn 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); - descsCache.add(key); // 必须:子类属性覆盖父类属性 } continue; } @@ -253,7 +253,6 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn // 替换 getter setter 的 this 为实际的 realm global。 // 例:(window.)location, (window.)document。 overriddenDescs[key] = materializeDescriptor(desc, realmGlobal); - descsCache.add(key); // 必须:子类属性覆盖父类属性 } } }; From 20f429c07d1d35ba4d67e68a9e99567354e3fde0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:42:46 +0900 Subject: [PATCH 19/23] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=88=E4=BD=B5?= =?UTF-8?q?=E9=8C=AF=E8=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index df7010fd0..b242ba73d 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -952,102 +952,3 @@ describe("createProxyContext: deterministic realm contract", () => { expect(handler).toHaveBeenCalledTimes(1); }); }); - -// 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); - }); -}); From 65b9bae08c5acdb777eb929b774847a75565c44a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:45:52 +0900 Subject: [PATCH 20/23] userscript test --- example/tests/sandbox_compatibility_test.js | 1045 +++++++++++++++++ ...ndbox_test.js => sandbox_function_test.js} | 0 2 files changed, 1045 insertions(+) create mode 100644 example/tests/sandbox_compatibility_test.js rename example/tests/{sandbox_test.js => sandbox_function_test.js} (100%) diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js new file mode 100644 index 000000000..2515f3b49 --- /dev/null +++ b/example/tests/sandbox_compatibility_test.js @@ -0,0 +1,1045 @@ +// ==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。" + ); + + 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 From b5c36792ac1cf55463c0755afbeb7ed04b9b42ef Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:47:00 +0900 Subject: [PATCH 21/23] Update sandbox_compatibility_test.js --- example/tests/sandbox_compatibility_test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/tests/sandbox_compatibility_test.js b/example/tests/sandbox_compatibility_test.js index 2515f3b49..e4c865b98 100644 --- a/example/tests/sandbox_compatibility_test.js +++ b/example/tests/sandbox_compatibility_test.js @@ -254,7 +254,8 @@ let observedThis = this; () => functionGlobal === unsafeWindow, "page unsafeWindow", () => formatValue(functionGlobal), - "验证脚本 realm 的函数构造器能把 this 指向 page window。" + "验证脚本 realm 的函数构造器能把 this 指向 page window。", + { onFail: "WARN", onError: "WARN", required: false } ); const evalGlobal = read(() => From 6e52e92adf98c84c6b471748b84a3510622ebb35 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:05:19 +0900 Subject: [PATCH 22/23] fix `unsafeWindow.globalThis` --- src/app/service/content/create_context.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index a0f15204f..0c7174cad 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -103,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; From 7b54e85c6e03cdbf75a94040ca59a546e0eb4043 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:11:04 +0900 Subject: [PATCH 23/23] Update gm-api.spec.ts --- e2e/gm-api.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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);