🐛 修复 Firefox content world 沙盒自引用逃逸与接口物件被剥空 - #1697
Open
CodFrm wants to merge 2 commits into
Open
Conversation
cyfung1031
self-requested a review
August 28, 2026 11:00
Collaborator
|
跟 #1692 一样,需要实际可以重现问题的 userscript 进行确认 ( 沉浸式翻译 以外的独立脚本) |
Member
Author
有道理 我补一下 |
CodFrm
force-pushed
the
fix/firefox-userscript-dom-realm
branch
from
August 29, 2026 11:48
26b7e9d to
5db7c9a
Compare
)" This reverts commit e57a734.
沉浸式翻译等带 @grant 的 @inject-into content 脚本在 Firefox 上完全不工作。 根因有三处,都只在 Firefox 的 content world 触发:该 world 走 world: "USER_SCRIPT",全局是 Cu.Sandbox —— globalThis !== window,且 window / self / Node / NodeFilter 都不是它的自有属性(在原型上那个 982 属性的 Window 包装器里,而该包装器的原型是 null,结构反射到此为止)。 Chrome 下 global 本身就是 Window,三处判断都恒等成立,行为不变。 1. 自引用逃逸:自引用改写只认 desc.value === global,initOwnDescs 又只取 global 的自有描述符,于是沙盒里根本没有 window / self,with(this.$) 穿透到外层解析到 页面 window。脚本写在 self 上、再从 globalThis 读的对象因此丢失 (沉浸式翻译写 self.GM_fetch、读 globalThis.GM_fetch,于是回退原生 fetch, 跨域被拦,一个翻译请求都发不出去)。 改为 isRealmWindow(o) = o === global || o === window,并在描述符缺失时按真实 window 取值补出。 2. 接口物件被 bind 剥空:protoBaseDescs 分支对任何函数无差别 value.bind(root), 而 bind 的产物没有 prototype、也丢掉全部静态成员,Node.ELEMENT_NODE / NodeFilter.SHOW_TEXT / XMLHttpRequest.DONE 全变 undefined,依赖这些常量的 DOM 遍历静默失效(沉浸式翻译因此一个段落都不标记)。新增 isBindableMethod,只 bind 「小写字头且无 prototype」的方法 —— 这正是 shouldFnBind 注释里既有的规则 (「小写字头能筛掉 NodeFilter 之类 Interface」),此前该分支没有照做。 3. 恢复上一提交撤销的两趟描述符收集(#1692)。经消融实验确认必需:关掉第二趟后 globalThis.addEventListener 回到 undefined。原型链实测佐证 —— 从 global 出发 的结构反射在那个原型为 null 的 Window 包装器处截断,EventTarget.prototype 的 三个方法只能由 window 那条链补齐;而 Math 从 window 看是 0 个静态成员、从 global 看是 45 个,所以两个根必须分别取。
CodFrm
force-pushed
the
fix/firefox-userscript-dom-realm
branch
from
August 29, 2026 11:50
5db7c9a to
80ebd8c
Compare
Member
Author
|
@cyfung1031 我重新整理了一下,用claude重新修复了,Revert了之前的处理 // ==UserScript==
// @name Firefox @inject-into content 沙盒缺陷复现
// @namespace https://github.com/scriptscat/scriptcat
// @version 1.1.0
// @match https://example.com/*
// @inject-into content
// @run-at document-end
// @grant GM_getValue
// ==/UserScript==
// @grant 必须非 none:@grant none 时 ScriptCat 不建沙盒(exec_script.ts 的 execContext = global),
// 这些问题在那条路径上全部不存在,换成 none 会「复现不出来」。
(function () {
"use strict";
const results = [];
const check = (name, fn) => {
try {
const d = fn();
results.push({ name, status: d === true ? "passed" : "failed", detail: String(d) });
} catch (e) {
results.push({ name, status: "failed", detail: "THREW: " + String((e && e.message) || e) });
}
};
// ① 自引用逃逸
check("window === globalThis", () => window === globalThis || "window !== globalThis");
check("self === globalThis", () => self === globalThis || "self !== globalThis");
check("GM API 在 window 上可见", () =>
typeof window.GM_getValue === "function" || "typeof = " + typeof window.GM_getValue);
check("写 self 能从 globalThis 读回", () => { // 沉浸式翻译的真实写法
self.__scProbe = { tag: "on-self" }; // self.GM_fetch = ...
const back = globalThis.__scProbe; // { fetchPolyfill: globalThis.GM_fetch }
delete self.__scProbe;
return (back && back.tag === "on-self") || "globalThis.__scProbe = " + String(back);
});
// ② 接口物件被 bind 剥空
check("Node.ELEMENT_NODE 常量存在", () =>
Node.ELEMENT_NODE === 1 || "Node.ELEMENT_NODE = " + String(Node.ELEMENT_NODE));
check("NodeFilter.SHOW_TEXT 常量存在", () =>
NodeFilter.SHOW_TEXT === 4 || "NodeFilter.SHOW_TEXT = " + String(NodeFilter.SHOW_TEXT));
check("XMLHttpRequest.DONE 常量存在", () =>
XMLHttpRequest.DONE === 4 || "XMLHttpRequest.DONE = " + String(XMLHttpRequest.DONE));
check("接口物件保留 prototype", () =>
Object.getPrototypeOf(document.body) === HTMLBodyElement.prototype ||
"HTMLBodyElement.prototype = " + String(HTMLBodyElement.prototype));
// ③ EventTarget
check("globalThis.addEventListener 可用", () =>
typeof globalThis.addEventListener === "function" || "typeof = " + typeof globalThis.addEventListener);
check("EventTarget 事件生命周期可用", () => {
let calls = 0;
const listener = () => { calls += 1; };
globalThis.addEventListener("sc-repro-evt", listener);
try { globalThis.dispatchEvent(new Event("sc-repro-evt")); }
finally { globalThis.removeEventListener("sc-repro-evt", listener); }
return calls === 1 || "listener 被调用 " + calls + " 次";
});
const failed = results.filter((r) => r.status === "failed").length;
const summary = "[ScriptCat 复现] passed: " + (results.length - failed) + ", failed: " + failed;
console.log(summary);
results.forEach((r) => {
const line = r.status + ": " + r.name + (r.status === "failed" ? " (" + r.detail + ")" : "");
(r.status === "failed" ? console.error : console.log)(line);
});
const panel = document.createElement("pre");
panel.textContent = [summary, ""].concat(
results.map((r) => r.status + ": " + r.name + (r.status === "failed" ? " → " + r.detail : ""))
).join("\n");
panel.style.cssText =
"position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:calc(100vw - 32px);padding:12px;" +
"border:2px solid " + (failed ? "#dc2626" : "#16a34a") +
";border-radius:8px;background:#fff;color:#111;font:12px/1.5 monospace;white-space:pre-wrap;";
document.documentElement.appendChild(panel);
})(); |
Collaborator
|
#1706 重新處理 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Checklist / 检查清单
N/A — 无对应 open issue,问题来自用户直接反馈(沉浸式翻译在 Firefox 上不翻译,Chrome 正常)。
背景
带
@grant(非none)的@inject-into content脚本在 Firefox 上完全不工作。以沉浸式翻译为例:Chrome 正常翻译,Firefox 下一个翻译请求都发不出去,页面无任何译文。该类脚本走
world: "USER_SCRIPT",Firefox 的这个 world 全局是Cu.Sandbox,与 Chrome 有两点结构差异(@grant none不建沙盒、走exec_script.ts的execContext = global,因此不受影响,也复现不出来):globalThis !== window;window/self/Node/NodeFilter等都不是global的自有属性,它们在原型上那个 982 属性的 Window 包装器里,而该包装器的原型是null—— 从global出发的结构反射到此截断。实测原型链(Firefox 154,
@grant none):注意 Xray 的不对称:取值能解析到继承成员(裸 content world 里
globalThis.addEventListener是 function),但getOwnPropertyNames/getPrototypeOf这条结构反射路径被过滤。沙盒是按描述符复制构造的,因此只丢在结构那一侧。本次改动
三处,都只在 Firefox 的 content world 触发。Chrome 下
global本身就是 Window,三处判断都恒等成立,行为不变(已用 Chrome e2e 对照确认)。① 自引用逃逸 — 自引用改写只认
desc.value === global,initOwnDescs又只取global的自有描述符,于是沙盒里根本没有window/self,with(this.$)穿透到外层解析到页面 window。沉浸式翻译的传输层正好踩中:写
self.GM_fetch = <基于 GM.xmlHttpRequest 的 fetch>,消费时读{ fetchPolyfill: globalThis.GM_fetch }—— 一写一读落到两个对象。修复前实测:于是
fetchPolyfill: undefined→ 回退原生fetch→ 跨域被拦 → 请求发不出去。改为
isRealmWindow(o) = o === global || o === window,并在描述符缺失时按真实window取值补出描述符,交给既有的自引用改写。② 接口物件被
bind剥空 —protoBaseDescs分支对任何函数无差别value.bind(root),而bind的产物没有prototype、也丢掉全部静态成员。实测沙盒里Node的自有属性只剩length,name:Node.ELEMENT_NODE1undefinedNodeFilter.SHOW_TEXT4undefinedXMLHttpRequest.DONE4undefinedgetPrototypeOf(body) === HTMLBodyElement.prototypetruefalse任何
node.nodeType === Node.ELEMENT_NODE的 DOM 遍历都会全量落空(沉浸式翻译因此一个段落都不标记、一个包装节点都不建)。新增
isBindableMethod,只 bind「小写字头且无 prototype」的方法。这条规则不是新发明的 ——shouldFnBind的注释里早就写着「要求函数名字小写字头 能筛选掉 NodeFilter 之类 Interface(大写开头不用于直接呼叫)」,只是protoBaseDescs这一支没有照做。③ 恢复两趟描述符收集(#1692,本分支第一个提交先把它 revert 掉,第二个提交连同上述修复一起补回)。经消融实验确认必需,不是靠推理保留的:关掉第二趟后
globalThis.addEventListener回到undefined。同时Math从window看是 0 个静态成员、从global看是 45 个,所以两个根必须分别取 —— 一刀切换成window会改成丢 JS 内置。实现考虑
为什么 ① 和 ② 都要修。 三者互相独立,缺一不可,逐项消融验证过:只修 ① 请求能发出去但页面不渲染(段落遍历落空);①+② 才走到渲染;缺 ③ 则
globalThis.addEventListener直接抛 TypeError。为什么 ② 用命名规则而不是「有 prototype 就不 bind」。 先按
!("prototype" in value)试过,Node/Event/XMLHttpRequest/HTMLBodyElement都恢复了,但NodeFilter仍是undefined—— 它是回调接口,本身没有prototype。小写字头这条规则同时覆盖两种形状,且与shouldFnBind既有判定一致。isBindableMethod与shouldFnBind的区别。 前者不做原生代码toString测试:走到protoBaseDescs分支的函数已经被shouldFnBind拒绝过一次(可能因为被扩展 Proxy 封装而toString不匹配),这类方法同样需要 bind 才能正确取到this。已知限制
以下两项本 PR 未处理,与本次改动无因果关系(修复前后均稳定复现),建议单独开 issue:
resource://gre/modules/Schemas.sys.mjs:159 TypeError: can't access property "call", getter is null—— Firefox 的 WebExtension API 用一次性exportLazyGetter,create_context捕获其get后二次调用会命中已清空的闭包。上一版 PR 内容(26b7e9d0)曾尝试用bindPropertyDescriptorToRoot修这一条,本次强制覆盖后已不在分支上。另:验证只覆盖 Firefox 154.0.1 与 Chrome,未测其他 Firefox 版本;顶层 frame 场景已验,iframe 下
top/parent的自引用行为仅由代码路径推导(window.top !== window时不改写),未实测。与上一版的差异
26b7e9d0走的是「硬编码 12 个 DOM 构造器名单,从window覆盖到沙盒」+「lazy getter 改用Reflect.get透传」。本版不用白名单:isBindableMethod从成因上解决同一类问题(不止那 12 个,XMLHttpRequest.DONE、KeyboardEvent.DOM_KEY_LOCATION_*等同样恢复),代价是不再包含上述 lazy getter 修复。建议审查重点
isRealmWindow把window也算作「本 realm 的 window」是否会误伤 iframe 场景:top/parent在子 frame 中window.top !== window,应保持指向真实 top,不被改写为沙盒。ownDescs[key] ??= { value: window[key], ... }合成的描述符缺少writable(刻意,随后若命中自引用会转成 getter;未命中时保持不可写,与真实 Window 一致)。isBindableMethod放宽了「原生代码 toString」检查,确认不会把页面注入的同名普通函数错误 bind。window === global⇒isRealmWindow退化为o === global;ownDescs[key]恒存在 ⇒??=不触发;Node等在initOwnDescs内 ⇒ 走不到protoBaseDescs分支。验证
head SHA
80ebd8cd0b13954523078935f1cbea6a3e4e3d97,baseorigin/main=f8cc26e6。最终 diff 仅两个文件:单元测试
新增 3 条回归测试,在修复前的
create_context.ts上全部为红(git stash掉实现后运行):happy-dom 里
globalThis === window,该拓扑无区分力,因此用vi.stubGlobal("window", …)+vi.resetModules()构造「window与globalThis分属不同物件、成员只在window原型链上」来建模;该文件已在vitest.config.ts的ISOLATED名单内(模块级sharedInitCopy需要独立模块环境)。tsc --noEmit/prettier --check/eslint由 pre-commit 钩子在两个提交上各跑一次,均通过。未跑全量vitest run(仅跑了src/app/service/content/)。Firefox 真实扩展验证
pnpm run build产物 +createFirefoxManifest()生成解包目录,经 geckodriver(--allow-system-access)安装为临时扩展。userScripts与<all_urls>在 Firefox MV3 下是可选权限且需用户手势,headless 无法点授权弹窗,改由 chrome context 调ExtensionPermissions.add()预置(等价于用户点「允许」)。脚本经serviceWorker/script/installByCode种入。环境:Firefox 154.0.1 / geckodriver 0.37.1 / macOS,目标页https://example.com/。独立复现脚本(
@inject-into content+@grant GM_getValue,不依赖沉浸式翻译,10 项断言):修复前逐项输出:
沉浸式翻译 v1.32.7 真脚本(
installByCode种入,经 popupmenuClick触发「翻译网页」):fetch数data-imt-ptargets: 11,cjk: true修复后页面实际渲染结果:
Chrome 对照(无回归)
同一份仪表化脚本经 Playwright +
testWithUserScripts跑 Chrome:win===gt=true、全程GM.xmlHttpRequest、targets: 2,与修复前逐项一致。Screenshots / 截图
N/A — 非视觉改动,证据为上文终端输出与页面文本。
关联
无其他关联 issue。本问题来自用户直接反馈,仓库内未找到对应的 open issue。