Skip to content

🐛 修复 Firefox content world 沙盒自引用逃逸与接口物件被剥空 - #1697

Open
CodFrm wants to merge 2 commits into
mainfrom
fix/firefox-userscript-dom-realm
Open

🐛 修复 Firefox content world 沙盒自引用逃逸与接口物件被剥空#1697
CodFrm wants to merge 2 commits into
mainfrom
fix/firefox-userscript-dom-realm

Conversation

@CodFrm

@CodFrm CodFrm commented Aug 28, 2026

Copy link
Copy Markdown
Member

Checklist / 检查清单

  • Fixes mentioned issues / 修复已提及的问题
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

N/A — 无对应 open issue,问题来自用户直接反馈(沉浸式翻译在 Firefox 上不翻译,Chrome 正常)。

本 PR 已强制推送整体重写,26b7e9d0 的原内容不再保留,详见「与上一版的差异」。

背景

@grant(非 none)的 @inject-into content 脚本在 Firefox 上完全不工作。以沉浸式翻译为例:Chrome 正常翻译,Firefox 下一个翻译请求都发不出去,页面无任何译文。

该类脚本走 world: "USER_SCRIPT",Firefox 的这个 world 全局是 Cu.Sandbox,与 Chrome 有两点结构差异(@grant none 不建沙盒、走 exec_script.tsexecContext = global,因此不受影响,也复现不出来):

  • globalThis !== window
  • window / self / Node / NodeFilter都不是 global 的自有属性,它们在原型上那个 982 属性的 Window 包装器里,而该包装器的原型是 null —— 从 global 出发的结构反射到此截断。

实测原型链(Firefox 154,@grant none):

globalThis (Cu.Sandbox, 78 个自有属性: Number/Math 等 JS 内置)
  └─ [object Window] (982 个自有属性, 有 window/self/fetch/document, 无 addEventListener)
       └─ null                                    ← 断在这里

window (同一个 982 属性 Window)
  └─ Window(0) → EventTarget(0) → EventTarget(4, addEventListener 在这) → Object.prototype

注意 Xray 的不对称:取值能解析到继承成员(裸 content world 里 globalThis.addEventListener 是 function),但 getOwnPropertyNames / getPrototypeOf 这条结构反射路径被过滤。沙盒是按描述符复制构造的,因此只丢在结构那一侧。

本次改动

三处,都只在 Firefox 的 content world 触发。Chrome 下 global 本身就是 Window,三处判断都恒等成立,行为不变(已用 Chrome e2e 对照确认)。

① 自引用逃逸 — 自引用改写只认 desc.value === globalinitOwnDescs 又只取 global 的自有描述符,于是沙盒里根本没有 window / selfwith(this.$) 穿透到外层解析到页面 window

沉浸式翻译的传输层正好踩中:写 self.GM_fetch = <基于 GM.xmlHttpRequest 的 fetch>,消费时读 { fetchPolyfill: globalThis.GM_fetch } —— 一写一读落到两个对象。修复前实测:

globalThis.GM_fetch = undefined        ← 读的这个
self.GM_fetch       = function(imt)    ← 写的这个(落到页面 window)
window.GM_fetch     = function(imt)

于是 fetchPolyfill: undefined → 回退原生 fetch → 跨域被拦 → 请求发不出去。

改为 isRealmWindow(o) = o === global || o === window,并在描述符缺失时按真实 window 取值补出描述符,交给既有的自引用改写。

② 接口物件被 bind 剥空protoBaseDescs 分支对任何函数无差别 value.bind(root),而 bind 的产物没有 prototype、也丢掉全部静态成员。实测沙盒里 Node 的自有属性只剩 length,name

裸 content world 修复前沙盒
Node.ELEMENT_NODE 1 undefined
NodeFilter.SHOW_TEXT 4 undefined
XMLHttpRequest.DONE 4 undefined
getPrototypeOf(body) === HTMLBodyElement.prototype true false

任何 node.nodeType === Node.ELEMENT_NODE 的 DOM 遍历都会全量落空(沉浸式翻译因此一个段落都不标记、一个包装节点都不建)。

新增 isBindableMethod,只 bind「小写字头且无 prototype」的方法。这条规则不是新发明的 —— shouldFnBind 的注释里早就写着「要求函数名字小写字头 能筛选掉 NodeFilter 之类 Interface(大写开头不用于直接呼叫)」,只是 protoBaseDescs 这一支没有照做。

③ 恢复两趟描述符收集#1692,本分支第一个提交先把它 revert 掉,第二个提交连同上述修复一起补回)。经消融实验确认必需,不是靠推理保留的:关掉第二趟后 globalThis.addEventListener 回到 undefined。同时 Mathwindow 看是 0 个静态成员、从 global 看是 45 个,所以两个根必须分别取 —— 一刀切换成 window 会改成丢 JS 内置。

实现考虑

为什么 ① 和 ② 都要修。 三者互相独立,缺一不可,逐项消融验证过:只修 ① 请求能发出去但页面不渲染(段落遍历落空);①+② 才走到渲染;缺 ③ 则 globalThis.addEventListener 直接抛 TypeError。

为什么 ② 用命名规则而不是「有 prototype 就不 bind」。 先按 !("prototype" in value) 试过,Node / Event / XMLHttpRequest / HTMLBodyElement 都恢复了,但 NodeFilter 仍是 undefined —— 它是回调接口,本身没有 prototype。小写字头这条规则同时覆盖两种形状,且与 shouldFnBind 既有判定一致。

isBindableMethodshouldFnBind 的区别。 前者不做原生代码 toString 测试:走到 protoBaseDescs 分支的函数已经被 shouldFnBind 拒绝过一次(可能因为被扩展 Proxy 封装而 toString 不匹配),这类方法同样需要 bind 才能正确取到 this

已知限制

以下两项本 PR 未处理,与本次改动无因果关系(修复前后均稳定复现),建议单独开 issue:

  1. resource://gre/modules/Schemas.sys.mjs:159 TypeError: can't access property "call", getter is null —— Firefox 的 WebExtension API 用一次性 exportLazyGettercreate_context 捕获其 get 后二次调用会命中已清空的闭包。上一版 PR 内容(26b7e9d0)曾尝试用 bindPropertyDescriptorToRoot 修这一条,本次强制覆盖后已不在分支上。
  2. Firefox 的 popup 把 3 个菜单列成 6 条(同一 document 内重复注册未去重,Chrome 只有 3 条)。

另:验证只覆盖 Firefox 154.0.1 与 Chrome,未测其他 Firefox 版本;顶层 frame 场景已验,iframe 下 top / parent 的自引用行为仅由代码路径推导(window.top !== window 时不改写),未实测。

与上一版的差异

26b7e9d0 走的是「硬编码 12 个 DOM 构造器名单,从 window 覆盖到沙盒」+「lazy getter 改用 Reflect.get 透传」。本版不用白名单:isBindableMethod 从成因上解决同一类问题(不止那 12 个,XMLHttpRequest.DONEKeyboardEvent.DOM_KEY_LOCATION_* 等同样恢复),代价是不再包含上述 lazy getter 修复。

建议审查重点

  • isRealmWindowwindow 也算作「本 realm 的 window」是否会误伤 iframe 场景:top / parent 在子 frame 中 window.top !== window,应保持指向真实 top,不被改写为沙盒。
  • ownDescs[key] ??= { value: window[key], ... } 合成的描述符缺少 writable(刻意,随后若命中自引用会转成 getter;未命中时保持不可写,与真实 Window 一致)。
  • isBindableMethod 放宽了「原生代码 toString」检查,确认不会把页面注入的同名普通函数错误 bind。
  • Chrome 路径确为恒等:window === globalisRealmWindow 退化为 o === globalownDescs[key] 恒存在 ⇒ ??= 不触发;Node 等在 initOwnDescs 内 ⇒ 走不到 protoBaseDescs 分支。

验证

head SHA 80ebd8cd0b13954523078935f1cbea6a3e4e3d97,base origin/main = f8cc26e6。最终 diff 仅两个文件:

$ git diff --stat origin/main...80ebd8cd
 src/app/service/content/create_context.test.ts | 99 +++++++++++++++++++++
 src/app/service/content/create_context.ts      | 39 +++++++--
 2 files changed, 131 insertions(+), 7 deletions(-)

单元测试

npx vitest run src/app/service/content/            → 11 files / 196 tests passed
npx vitest run .../create_context.test.ts          → 15 passed

新增 3 条回归测试,在修复前的 create_context.ts全部为红git stash 掉实现后运行):

× 沙盒补齐只能经 window 原型链取得的成员 (#1692)
× 接口物件保留 prototype 与静态常量,不被 bind 剥空
× window / self 指向沙盒自身,不逃逸到页面 window
  → 3 failed | 12 passed

happy-dom 里 globalThis === window,该拓扑无区分力,因此用 vi.stubGlobal("window", …) + vi.resetModules() 构造「windowglobalThis 分属不同物件、成员只在 window 原型链上」来建模;该文件已在 vitest.config.tsISOLATED 名单内(模块级 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 项断言):

修复前 修复后
复现脚本 0 / 10 10 / 10

修复前逐项输出:

failed: window === globalThis            → window !== globalThis
failed: self === globalThis              → self !== globalThis
failed: GM API 在 window 上可见           → typeof window.GM_getValue = undefined
failed: 写 self 能从 globalThis 读回       → globalThis.__scReproProbe = undefined
failed: Node.ELEMENT_NODE 常量存在        → Node.ELEMENT_NODE = undefined
failed: NodeFilter.SHOW_TEXT 常量存在     → NodeFilter.SHOW_TEXT = undefined
failed: XMLHttpRequest.DONE 常量存在      → XMLHttpRequest.DONE = undefined
failed: 接口物件保留 prototype            → HTMLBodyElement.prototype = [object HTMLElement]
failed: globalThis.addEventListener 可用  → typeof = undefined
failed: EventTarget 事件生命周期可用       → THREW: globalThis.addEventListener is not a function

沉浸式翻译 v1.32.7 真脚本(installByCode 种入,经 popup menuClick 触发「翻译网页」):

修复前 修复后
GM 请求数 0 36
fetch 全部 1(谷歌统计,Chrome 亦失败)
段落标记 data-imt-p 3
译文节点 0 targets: 11cjk: true

修复后页面实际渲染结果:

Example Domain  示例领域

This domain is for use in documentation examples without needing permission. Avoid use in operations.
这个域名可以用于文档示例的展示,无需获得许可即可使用。但请勿在正式操作中使用该域名。

Learn more  了解更多

Chrome 对照(无回归)

同一份仪表化脚本经 Playwright + testWithUserScripts 跑 Chrome:win===gt=true、全程 GM.xmlHttpRequesttargets: 2,与修复前逐项一致。

上述 Firefox/Chrome 驱动脚本放在 e2e/scratch/(已 gitignore),不随本 PR 提交。

Screenshots / 截图

N/A — 非视觉改动,证据为上文终端输出与页面文本。

关联

无其他关联 issue。本问题来自用户直接反馈,仓库内未找到对应的 open issue。

@cyfung1031
cyfung1031 self-requested a review August 28, 2026 11:00
@cyfung1031

cyfung1031 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

#1692 一样,需要实际可以重现问题的 userscript 进行确认 ( 沉浸式翻译 以外的独立脚本)
不然就违反了TDD

@CodFrm

CodFrm commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

#1692 一样,需要实际可以重现问题的 userscript 进行确认 ( 沉浸式翻译 以外的独立脚本)

不然就违反了TDD

有道理 我补一下

@CodFrm
CodFrm force-pushed the fix/firefox-userscript-dom-realm branch from 26b7e9d to 5db7c9a Compare August 29, 2026 11:48
CodFrm added 2 commits August 29, 2026 19:49
沉浸式翻译等带 @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
CodFrm force-pushed the fix/firefox-userscript-dom-realm branch from 5db7c9a to 80ebd8c Compare August 29, 2026 11:50
@CodFrm

CodFrm commented Aug 29, 2026

Copy link
Copy Markdown
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);
})();

@CodFrm CodFrm changed the title 🐛 修复 Firefox 用户脚本 DOM realm 不一致 🐛 修复 Firefox content world 沙盒自引用逃逸与接口物件被剥空 Aug 29, 2026
@cyfung1031

Copy link
Copy Markdown
Collaborator

#1706 重新處理

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants