桌面 App 里的第三方嵌入,为什么必须被关进沙箱


你在桌面 App 的聊天里贴了一个 YouTube 链接,点开播放区只弹出一行 “Video player configuration error”。换成一条 X 或 Instagram 的帖子,它倒是好好地显示了,但你多半没想过:那条帖子的渲染脚本,正跑在一个能启动终端、读写本地文件的窗口里。这两件看起来互不相干的小事,其实是同一类问题的两面——桌面 App 把不受自己控制的外部网页内容,请进了那个握有 App 全部权限的主窗口。这篇文章讲清问题出在哪、官方怎么改的,以及你现在需不需要做点什么。

第三方脚本跑在能操作你终端的窗口里

先弄清楚那个「主窗口」为什么敏感。Hermes 桌面 App 的渲染进程里挂着一个预加载桥 window.hermesDesktop,它就是前端调用本机能力的那根管子:终端、文件、后端 API 都从这里走。换句话说,任何能在渲染进程里执行的脚本,都能伸手去调这个桥,其中就包括 terminal.start 和 terminal.write——凭空开一个终端、往里打字,对你来说和自己在键盘上敲没什么两样。

修复之前,apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx 的做法很直接:把 platform.twitter.com/widgets.js 和 www.instagram.com/embed.js 直接追加进主渲染进程的文档里。这两个脚本来自第三方服务器,谁能在传输途中动它们,或者干脆提供一个被篡改的版本,谁就能把代码写进这个拥有全部权限的窗口。

这正是公开漏洞 GHSA-5rwg-hf29-9hm8(CWE-829,指「从不受信任的来源引入可执行代码」)对应的代码修复。问题由 @Froraut 发现并报告,修复在 PR #130939 中完成。

修复一:X 和 Instagram 搬进沙箱 iframe

修复思路一句话就能说完:既然脚本不可信,就别让它进自家门,改成让它在别人家的地盘上跑。

现在 X 和 Instagram 都渲染在一个跨源沙箱 iframe 里。iframe 上写的是 sandbox="allow-scripts allow-same-origin",只留了嵌入页真正需要的东西:能跑脚本,能用自己的 origin 存取自己的 cookie 和存储。顶层导航、弹窗、表单、模态统统被去掉。这里有个容易看走眼的点:allow-same-origin 听起来吓人,但因为这个 iframe 永远是跨源的,它拿到的是「对方自己」的源,对你 App 的资源和权限一无所得;而且 Electron 不会往子框架里注入 preload 脚本,那个危险的主窗口桥根本不在里面。

两家的页面地址也不一样。X 加载的是 https://platform.twitter.com/embed/Tweet.html?id=…&theme=…&dnt=true,也就是帖子 id、主题(深色/浅色跟随 App)和 dnt=true 三个参数;Instagram 则继续用它原来的 embed URL。

还有一个细节值得单独说:嵌入内容的高度只通过 message 事件跟过来,而且父页面必须同时满足两个条件才认——event.source 正好是那个 iframe 的 contentWindow,证明消息确实来自这个框;event.origin 精确等于对应服务商的域名。高度值本身也只接受有限的正数,其余一律丢掉。这样一来,别的页面或脚本没法伪造一条「把高度设成某个值」的消息来捣乱。替换完成后,旧的 escape-html.ts 也一并删掉了,因为不再有任何东西需要注入 App 文档。

修复二:YouTube 通过本机回环页播放

YouTube 是另一路问题,不是安全,而是「放不出来」。

打包版桌面 App 的渲染进程是一个 file:// 页面,而 YouTube 要求嵌入它的页面有一个 http(s) origin,并且要和播放器的 origin 参数对得上,否则直接报错 153(视频播放器配置错误)。再加上现代 Electron 会把带上去的 Referer 头丢掉,file:// 页面就彻底没法自己承载播放器。

官方的做法,是在主进程里起一个回环小主机(apps/desktop/electron/embed-host.ts):在 127.0.0.1 的随机端口(port: 0,交给系统分配)上,为每个视频提供一张极小的包装页。这台主机只认 /youtube/<11 位视频 id> 这一种路径;它只透传三个数值型参数——start、rel、modestbranding,而且都用 ^\d+$ 校验过,非数字直接忽略;同时给播放器补上 origin 参数,让校验通过。

这层小主机被收得很紧:

  • 只回答 Host 头等于它自己的请求,所以别的页面就算通过 DNS rebinding 指到这个端口,也拿不到任何东西;
  • 响应头带 Cache-Control: no-store 和 X-Content-Type-Options: nosniff;
  • 还有一条 CSP:default-src 'none'; frame-src https://www.youtube-nocookie.com; style-src 'unsafe-inline',除了允许框住 YouTube 播放器,别的资源一律不放行。

关键是,渲染进程仍然留在 file://:它的 origin、localStorage、设置、后端连接统统不动,只有那张包装页获得了 http origin。这个方案替代了更早的 #127201——那个版本想把整个渲染进程搬到回环地址上,结果更新后用户的设置被重置、远程后端也拒绝新的 origin,最终已在 #130852 被撤回。

还有一种你可能会遇到的版本错配:新渲染进程配旧主进程时,拿不到 getEmbedHostOrigin,App 就直接嵌入,行为和你今天见到的完全一样;同理,如果小主机起不来,也会回退到直接 iframe。所以这个改动不会让谁彻底播放不了,只是能修好的就修好。

修复三:畸形路径不再崩掉主进程

回环小主机上线没多久,就补了一个后续修复(PR #131015)。

问题出在一个不起眼的解析上。请求路径如果是 // 或 //[ 这类畸形值,new URL('//', origin) 会在 http.createServer 的回调里抛出未捕获的 TypeError: Invalid URL,直接让主进程崩溃——crash-forensics 会把它记成一次崩溃,而这个 socket 永远等不到响应。因为 Host 检查就排在前面,任何本机进程只要带上一个正确的 Host 头,就能触发它。

修复很轻巧:youtubeWrapperFor() 里先用 URL.canParse 判断一下,解析不了就返回 null,请求处理器于是回一个 404,而不是把整个进程带走。

这次修复里值得学的三个习惯

抛开具体代码,这三个提交里有几个做法,对任何做桌面或混合应用的人都通用。

一、能用最小权限就别给大权限。 沙箱 iframe 只保留 allow-scripts allow-same-origin,顶层导航、弹窗、表单、模态全部拿掉;回环主机上的 CSP 也是白名单,只开 frame-src。默认拒绝、按需放行,比「先全开再打补丁」稳得多。

二、只信任被双重校验过的消息。 跨源 iframe 之间通信只能靠 postMessage,而任何页面都能往你的窗口发消息。所以父页面不能看到一条消息就照做,必须同时确认「来源窗口正是这个 iframe」和「消息来自预期的服务商域名」,数值还要再卡一道。三个条件缺一不可,否则就是一个能被外部页面篡改的入口。

三、能用局部最小改动就别做整仓迁移。 #127201 想解决的问题(YouTube 播不了)和 #130975 一样,但它选的是「把整个渲染进程搬到回环地址」,代价是 origin、存储、后端信任全都要跟着重配,最终被撤回。新方案只在主进程里加了一个只为视频服务的小主机,渲染进程原地不动。同样的问题,改动面小一个数量级,回滚成本也低得多。

版本状态与你要做什么

先把必须诚实交代的部分说清楚:这三个改动目前只存在于 main 分支,还没有进入任何正式版本。

项 状态
最新正式版 v0.21.5(tag v2026.9.24,2026-09-24 发布)
三个改动合入时间 2026-10-01 至 2026-10-02
是否进入正式版 否,目前仅在 main
用户需要配置吗 不需要,升级即生效

对你的实际影响,取决于你怎么装:

  • 如果你跟踪的是 main 分支(比如从源码跑,或走 canary 通道),下一次 hermes update 会带上这三个改动;
  • 如果你用的是正式版,要等到下一个正式 tag 才会拿到;
  • 无论哪种情况,都不需要改任何配置、任何字段——它是一组纯粹的修复。

如果你平时会配置审批、密钥这类安全相关的东西,也可以顺手看看安全命令参考和密钥加密那篇,理解 Hermes 在「哪些操作需要你点头」上的整体设计;这次的嵌入隔离,是同一套思路在桌面渲染层上的延伸。对桌面端行为感兴趣的,还可以对照聊天消息「版本过期」死循环修复。

小结

桌面 App 的便利,来自它同时握着界面、终端和文件。这份便利也意味着,任何被请进来的第三方内容,都站在了权力中心旁边。这次的三步修改——X / Instagram 进沙箱 iframe、YouTube 走本机回环包装页、畸形路径不再崩主进程——本质上是在做同一件事:把不可信的东西挪到够不着核心的位置,同时只保留它完成本职工作所需的最小能力。它现在只在 main 上,但方向值得记一笔。