Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/app/service/content/create_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,102 @@ describe.concurrent("createProxyContext", () => {
expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true);
});
});

// Firefox 的 content / USER_SCRIPT world 全局是 Cu.Sandbox:globalThis 与 window 分属两个 realm,
// 沙盒的原型链在 Xray window 处截断,EventTarget.prototype 上的成员只能经 window 取得。
// happy-dom 里 globalThis === window,只能用一个「仅存在于 window 原型链上」的成员模拟该拓扑。
describe("Firefox content world:globalThis 与 window 分属不同 realm", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it("沙盒补齐只能经 window 原型链取得的成员 (#1692)", async () => {
const windowProto = Object.create(null);
// 原生 DOM 方法没有 prototype,这里必须用同样形状(方法简写),否则模型不成立
windowProto.onlyReachableViaWindow = {
onlyReachableViaWindow(this: unknown) {
return this;
},
}.onlyReachableViaWindow;
const fakeWindow = Object.create(windowProto);
vi.stubGlobal("window", fakeWindow);
vi.resetModules();

const module = await import("./create_context.js");
const context = module.createContext(
createScriptInfo(),
{ script: { name: "create-context-test" }, scriptMetaStr: "" },
"vitest",
undefined as any,
undefined as any,
new Set<string>()
);
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<string>()
)
);

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<string, any> = 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<string>()
)
);

expect(sandbox.window).toBe(sandbox);
expect(sandbox.self).toBe(sandbox);
});
});
39 changes: 32 additions & 7 deletions src/app/service/content/create_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export const shouldFnBind = (f: any) => {
return false;
};

// 判断是否为「需要 this 的方法」。沿用 shouldFnBind 的结构判定(有 prototype 即为 Class;
// 小写字头才是可直接呼叫的方法,大写字头是 Node / NodeFilter 之类接口物件),但不做原生代码
// toString 测试 —— 被扩展 Proxy 封装过的方法同样需要 bind。
const isBindableMethod = (f: any) => {
if (typeof f !== "function") return false;
if ("prototype" in f) return false;
const { name } = f as typeof Function.prototype;
if (!name) return false;
const e = name.charCodeAt(0);
return e >= 97 && e <= 122 && !name.includes(" ");
};

type ForEachCallback<T> = (value: T, index: number, array: T[]) => void;

// 取物件本身及所有父类(不包含Object)的PropertyDescriptor
Expand Down Expand Up @@ -206,11 +218,15 @@ const collectPropertyDescriptors = (root: any) =>
descsCache.add(key); // 必须:子类属性覆盖父类属性
} else if (!(key in initOwnDescs) && !Object.hasOwn(root, key)) {
if (!protoBaseDescs[key]) {
if (typeof value === "function") {
const boundValue = value.bind(root);
// 只有「需要 this 的方法」才 bind。接口物件(Node、NodeFilter、Event、XMLHttpRequest…)
// bind 之后会丢掉 prototype 和全部静态成员,Node.ELEMENT_NODE / NodeFilter.SHOW_TEXT
// 之类的常量全部变成 undefined,DOM 遍历会静默失效。
// Chrome 下 global 就是 window,这些键都在 initOwnDescs 里、走不到这一支;
// Firefox 的 Cu.Sandbox 没有这些自有属性,不加判断就会把接口物件剥成 length/name。
if (isBindableMethod(value)) {
protoBaseDescs[key] = {
...desc,
value: boundValue,
value: value.bind(root),
};
} else {
protoBaseDescs[key] = { ...desc };
Expand Down Expand Up @@ -297,6 +313,12 @@ type GMWorldContext = typeof globalThis & Record<PropertyKey, any>;

const isPrimitive = (x: any) => x !== Object(x);

// 判断某个值是否为「本 realm 的 window」,即沙盒自引用应当改写成 mySandbox 的目标。
// Chrome 的 USER_SCRIPT world 里 global 本身就是 Window(window === global),只有一个候选;
// Firefox 的 content world 里 global 是 Cu.Sandbox、window 是页面 Window 的 Xray 包装,
// 两者都要算,否则 window / self / top / parent 会指回页面而不是沙盒。
const isRealmWindow = (o: any) => o === global || o === window;

// 拦截上下文
export const createProxyContext = <const Context extends GMWorldContext>(context: any): Context => {
// let withContext: Context | undefined | { [key: string]: any } = undefined;
Expand All @@ -310,7 +332,7 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
const createFuncWrapper = (f: () => any) => {
return function (this: any) {
const ret = f.call(global);
if (ret === global) return mySandbox;
if (isRealmWindow(ret)) return mySandbox;
return ret;
};
};
Expand Down Expand Up @@ -369,8 +391,11 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
}

for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) {
const desc = ownDescs[key];
if (desc?.value === global) {
// Firefox 的 content world 里 window / self / top / parent 都不是 Cu.Sandbox 的自有属性,
// 结构反射也拿不到(Sandbox 的原型是一个原型为 null 的 Window 包装),沙盒因此完全没有这些键,
// with(this.$) 会穿透到外层直接解析到页面 window。按真实取值补出描述符,交给下面的自引用改写。
const desc = (ownDescs[key] ??= { value: (<any>window)[key], enumerable: true, configurable: true });
if (isRealmWindow(desc.value)) {
// globalThis
// 避免 self referencing, 改以 getter 形式
desc.get = function () {
Expand All @@ -380,7 +405,7 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
// 为了 value 转 getter/setter,必须删除 writable 和 value
delete desc.writable;
delete desc.value;
} else if (desc?.get) {
} else if (desc.get) {
// 真实的 window 物件中部份属性(self, parent) 存在setter. 意义不明
// 为避免做成混乱,ScriptCat脚本的沙盒不提供setter(即不能修改)
// (像window.document, 能写 window.document = null 不会报错但赋值不变)
Expand Down
Loading