将 VS Code 迁移到进程沙箱
安全和 VS Code 架构的双赢
2022 年 11 月 28 日,作者:Benjamin Pasero,@BenjaminPasero
在 Electron 渲染进程中启用沙箱是像 Visual Studio Code 这样安全可靠的 Electron 应用的关键要求。沙箱通过限制对大多数系统资源的访问来减少恶意代码可能造成的损害。在这篇博客文章中,我们详细介绍了如何在 VS Code 中启用进程沙箱,这是一段我们始于 2020 年初并计划在 2023 年初完成的旅程。为了帮助理解进程沙箱的挑战,这篇博客文章还描述了 VS Code 进程模型的详细信息以及它在这段旅程中如何演变。
这是一项团队努力,因为几乎所有 VS Code 组件都需要进行基础架构更改和代码修改。VS Code 的进程架构被彻底改造,并在过程中显著加强。我们重点介绍了过程中的主要里程碑,希望为其他人提供宝贵的参考。过去几个月,进程沙箱模式已在 VS Code Insiders 版本中成功运行,为我们提供了关于此更改影响的反馈。如果您发现问题、对如何改进体验有建议或有一般性问题,请随时联系我们。
如果您不熟悉 VS Code、Electron 或沙箱,建议您先阅读博客文章末尾的术语部分。在那里您将找到所用术语的解释以及背景资料的链接。
进程沙箱概述
长期以来,Electron 允许在 HTML 和 JavaScript 中直接使用 Node.js API。下面的代码片段提供了一个简单的网页示例,它不仅向用户打印“Hello World”,还向本地磁盘上的文件写入内容
负责向用户呈现网页的 Electron 进程称为渲染进程。为渲染进程启用沙箱模式可降低其功能,从而提高安全性并更贴合 Web 模型:虽然仍然允许使用 HTML 和 JavaScript,但不允许使用 Node.js。渲染进程中需要访问系统资源的组件必须委托给另一个非沙箱进程。
下面的代码不再依赖 Node.js,而是使用一个 vscode
全局变量,该变量提供了更新设置的功能。此方法的实现涉及向另一个有权访问 Node.js 的进程发送消息。因此,它也不再同步执行,而是异步执行
我们如何在渲染进程中拥有 vscode
全局变量以及它的实现方式,将在下面的时间线部分详细介绍。
阻止 Node.js 在渲染进程中使用是 Electron 鼓励的安全建议。过去我们遇到过安全问题,攻击者能够从渲染进程执行任意 Node.js 代码。沙箱化的渲染进程大大降低了此类攻击的风险。
我们是如何做到的?
像将所有 Node.js 依赖从渲染进程中移除这样大的改变,存在引入回归和 bug 的风险。之前在一个进程中运行的代码将不得不被拆分并在多个进程中运行。那些原生且因此无法进行 Web 打包的 Node 模块也必须移出。某些全局对象,例如 Node.js 的 Buffer,必须被浏览器兼容的变体替代,例如 Uint8Array。
下图显示了沙箱工作开始之前的进程架构。正如您所见,大多数进程都是从渲染进程派生的 Node.js 子进程(绿色部分)。大多数(进程间通信)IPC 是通过 Node.js 套接字实现的,并且渲染进程是 Node.js API 的主要客户端——例如读写文件。
我们很快决定要在不发布独立的沙箱化 VS Code 应用的情况下进行进程沙箱工作。我们希望逐步使 VS Code 渲染进程具备沙箱条件,然后在最后开启该功能。在过去几年里,我们每月发布稳定的 VS Code 版本,其中包含有助于实现沙箱目标但尚未完全启用的更改。想象一下,在空中飞行时对飞机进行根本性的重建。在我们的例子中,用户基本上没有意识到 VS Code 的这些变化。
我们的技术时间线
接下来的章节将详细介绍过去几年里沙箱功能的实现过程。主要任务是移除渲染进程中的所有 Node.js 依赖,但在此过程中也出现了更多挑战,例如在 MessagePort
的帮助下找到一种高效且适用于沙箱的 IPC 解决方案,或者为我们可以从渲染进程派生的各种 Node.js 子进程找到新的宿主。
在很大程度上,主题顺序遵循实际的时间线。为了使每个部分简洁明了,我们链接到其他文档和教程,以更详细地解释某个技术方面。尽管我们早在 2020 年初就计划了这项工作,但也不能忽略一些有助于完成此任务的先前工作。让我们仔细看看……
站在巨人的肩膀上
当我们在 2020 年初开始考虑沙箱时,我们已经发布了一个可以在 Web 浏览器中运行的 VS Code 版本。您可以在浏览器中运行 vscode.dev 并看到Web 版 Visual Studio Code 的实际运行。在创建 Web 版 VS Code 的过程中,我们学会了如何从工作台——即主要的 VS Code 用户界面窗口——中移除 Node.js 依赖。
移除对 Node.js 的依赖意味着寻找替代方案。例如,我们将对 Node.js Buffer
类型的依赖替换为了 VSBuffer 的等价物,该等价物在浏览器环境中会回退到 Uint8Array
。我们还能够将一些 Node.js 模块(oniguruma、iconv-lite)打包以便在 Web 环境中运行。
但在 Web 版 VS Code 成为现实之前,我们已经启用了对远程开发的支持,这允许在远程主机上编辑源代码,例如通过 SSH 连接(后来甚至为 GitHub Codespaces 提供支持)。对于远程开发,我们必须实现一种解决方案,即 VS Code 的 UI 面向部分在本地运行,而实际的文件操作在远程机器上运行。此模型也适用于沙箱化的工作台,特权操作必须在不同的进程中运行。在这两种情况下,渲染进程通过 IPC 与特权主机通信以执行操作。
启用从渲染进程到主进程的通信通道
当渲染进程无法使用 Node.js 时,必须将工作委托给另一个可以使用 Node.js 的进程。在 Web 环境中,一种解决方案可能依赖于 HTTP 方法,由服务器接受请求。然而,对于桌面应用程序来说,这感觉不是最佳解决方案,因为出于安全原因,防火墙可能会阻止在某个端口上运行本地服务器。
Electron 提供了将预加载脚本注入到渲染进程的能力,这些脚本在主脚本执行之前执行。这些脚本可以访问 Electron 自己的IPC 机制。预加载脚本可以通过上下文桥 API 丰富可供渲染器主脚本使用的 API。虽然预加载脚本可以直接使用 Electron 的 IPC,但主脚本不能。因此,我们通过上下文桥向主脚本暴露了某些方法。在我们开头使用的示例中,以下是用于更新设置的方法如何从预加载脚本暴露到主脚本的方式
预加载脚本是将特权代码与非特权代码分离的基础构建块。例如,向磁盘上的文件写入意味着包含新内容的 IPC 消息将从主脚本传输到预加载脚本,然后再从那里传输到有权访问 Node.js 的主进程。
通过消息端口实现快速进程间通信
引入预加载脚本后,我们有了一种让渲染进程与 Electron 主进程通信以安排工作的方式。然而,在 Electron 应用中,避免主进程负载过重至关重要,因为它也是负责处理用户输入(例如来自键盘和鼠标的输入)的进程。繁忙的主进程可能导致用户界面无响应。
这是我们之前遇到的一个问题。甚至在开始沙箱工作之前,我们就对将性能密集型代码卸载到后台进程——VS Code 共享进程——感兴趣。这个进程是一个隐藏窗口,所有工作台窗口和主进程都可以与之通信以执行复杂任务,例如扩展安装。例如,当您安装扩展时,请求会发送到共享进程以执行整个操作。
然而,与共享进程的通信是通过 Node.js 套接字实现的。这样做的好处是主进程没有任何开销,因为它根本不参与通信。缺点是 Node.js 套接字通信在沙箱化的渲染器中是不可能的,因为不能使用任何 Node.js API。
消息端口提供了一种强大的方式,通过在两个进程之间建立 IPC 通道来连接它们。即使是完全沙箱化的渲染进程也可以使用消息端口,因为它们在浏览器中作为Web API 提供。用消息端口替换 Node.js 套接字通信,使我们能够拥有与沙箱兼容的 IPC 解决方案,同时仍然保留不涉及主进程的性能优势。
跨进程边界传递消息端口是复杂的,特别是传递到带有预加载脚本的沙箱化渲染进程中。其序列流程如下图所示
- 共享进程创建消息端口 P1 和 P2,并保留 P1。
- P2 通过 Electron IPC 发送给主进程。
- 主进程将 P2 转发给请求的渲染进程。
- P2 最终到达该渲染进程的预加载脚本中。
- 预加载脚本将 P2 转发到渲染进程的主脚本中。
- 主脚本接收 P2 并可以使用它直接发送消息。
更改渲染进程的源
在 Web 浏览器中,您输入 URL 并加载并呈现内容。在 Electron 中,您不输入 URL,而是由应用程序为您决定加载和呈现哪些内容。因此,当您打开 VS Code 时,会加载一个窗口,其中包含预配置的 URL 以显示工作台的内容。
对于 VS Code,此 URL 使用了指向磁盘上实际文件的本地文件协议来加载 (file://<path to file on disk>
)。作为沙箱工作的一部分,我们重新审视了这种方法,因为它具有严重的安全隐患。与 HTTPS 协议相比,Chromium 对本地文件协议做了一些不那么严格的安全假设。例如,对于本地文件协议 URL 不会应用严格的源检查。
使用 Electron,您可以注册可用于将内容加载到渲染进程中的自定义协议。可以配置这些自定义协议,使其在安全方面与 HTTPS 协议表现相同。我们使用这种方法来避免运行服务内容的本地 Web 服务器。
随着为所有渲染进程引入自定义的 vscode-file
协议,我们得以放弃所有文件协议的使用。它被配置为行为类似于 HTTPS,这意味着我们离 Web 版 VS Code 的实际工作方式更近了一步。
调整我们的代码加载器
从历史上看,我们所有的 TypeScript 代码都被编译成AMD 模块,并使用我们多年来一直维护的自定义加载器加载。我们正计划放弃 AMD 并采用ESM,但这项工作尚处于早期阶段。
我们的代码加载器通过探测一些预定义的变量来确定实际运行环境,从而支持 Node.js 和 Web 环境。沙箱化的渲染器本质上就像一个 Web 环境,因此我们的加载器只需要很少的更改即可支持沙箱。
完成这些更改后,我们得以运行启用了沙箱模式的早期 VS Code 版本。然而,由于我们尚未将渲染进程从其 Node.js 依赖中解放出来,因此只显示了一个空白页,并在控制台输出了错误。
帮助采纳的工具
现在我们有了启用沙箱的方式来运行 VS Code,我们想投入工具开发,以便更容易地将依赖 Node.js 的源代码转换为“适用于沙箱”的代码。考虑到我们在 Web 版 VS Code 上的投入,我们已经有了静态分析工具,可以阻止 Node.js 代码发布到 Web 版本。这些工具定义了一套目标环境及其运行时要求。我们的工具可以检测并报告在不允许使用 Node.js 的目标环境中使用 Node.js 全局对象(例如 Buffer
)、Node.js API 或 Node 模块的情况。对于沙箱工作,我们添加了一个新的目标环境 electron-sandbox,它不允许使用任何 Node.js。通过将代码迁移到此环境中,我们能够逐步使代码适用于沙箱。
在下面的截图中,编辑器中出现一个警告标记,表明来自 browser 目标环境的文件依赖于 Node.js 中的 API。此警告会导致我们的构建失败,并阻止意外将此代码推送到发布版本。
我们的 Process Explorer 和 Issue Reporter 工具是首批符合 electron-sandbox 目标要求的工具之一。在工作台窗口完成适配之前,我们就能够完全沙箱化运行这些窗口。
将进程从渲染进程中移出
正如之前的主题详细解释的那样,将 Node.js 功能片段转移到另一个进程并使用 IPC 安排工作和接收结果,这可以是直截了当的。
然而,工作台中一些依赖于 Node.js 的组件更为复杂,特别是那些会派生子进程的组件,例如
- 扩展宿主
- 集成终端
- 文件监视
- 全文搜索
- 任务执行
- 调试
考虑到 VS Code 可以在远程场景中运行,我们已经有了在远程执行部分任务的机制,即:搜索、调试和任务执行。这些组件可以在扩展宿主进程中运行,该进程自然地运行在代码所在的位置。因此,即使 VS Code 在本地运行且没有连接远程,我们也能够将这些子进程的所有权从渲染进程转移到扩展宿主。
对于扩展宿主,我们有更宏大的计划。我们将在稍后专门的章节中介绍这些更改,因为它需要向 Electron 添加一个新的“实用进程”API。
集成终端和文件监视被移至成为共享进程的子进程。任何需要文件监视或集成终端的窗口将通过消息端口与共享进程通信以获取这些服务。
下图显示了我们到 2022 年底的进程架构,那时我们已在渲染进程中启用了沙箱。所有 Node.js 进程都已移至成为共享进程的子进程或从主进程派生的实用进程。消息端口用于高效的直接进程间通信,而不会给主进程带来负担。
调整 Chromium 的代码缓存
我们还想确保启用沙箱不会导致任何性能回归。我们测量了从启动到在编辑器中显示光标闪烁所需的时间,发现大量时间花费在 V8 JavaScript 引擎加载、解析和执行主工作台脚本(约 11.5 MB 的压缩代码)上。除非安装更新,否则每次启动都会加载相同的脚本。考虑到这种行为,V8 可以将脚本的优化版本存储在磁盘上,下次加载时会更快,这是通过代码缓存实现的。
Chromium 本身也使用代码缓存来加速网页加载时间。它触发的 V8 引擎优化与我们的解决方案相同,但 Chromium 的实现仅针对在特定时间内频繁访问的网页执行此操作。考虑到我们的应用是桌面应用而非网页,我们想要一个始终使用代码缓存的解决方案。
遗憾的是,我们的解决方案依赖于 Node.js,因此无法在沙箱化的渲染进程中应用。
通过在 Electron 中暴露代码缓存选项,我们可以在使用 bypassHeatCheck 选项时强制触发 Chromium 中的代码缓存。此外,我们通过在检测到用户正在运行更新版本的 VS Code 时丢弃之前生成的代码缓存,增加了一层额外的保护。
一个新的 Electron API:UtilityProcess
最后也是可能最复杂的任务是找到扩展宿主的迁移解决方案。与共享进程类似,通信是通过 Node.js 套接字实现的。每个窗口有一个扩展宿主进程,并且扩展可以随意派生任意数量的子进程。
我们曾考虑将扩展宿主像文件监视器和集成终端一样移入我们的共享进程,但觉得应该抓住机会构建一个更灵活、不需要隐藏窗口作为宿主的方案。
为此,我们想要一个健壮且可伸缩的解决方案,它既能在沙箱化渲染器中工作,又能保留大部分现有行为
- 支持派生子进程的隔离进程
- 完整的 Node.js 支持
- 使用消息端口与沙箱化进程进行直接 IPC
当时,Electron 无法为我们提供支持这些要求的 API,因此我们贡献了一个新的实用进程 API 到 Electron。这个 API 使我们能够将扩展宿主从渲染进程移出,并移到一个由主进程创建的实用进程中。使用消息端口,我们可以直接在渲染器和扩展宿主之间进行通信,而不会影响任何其他进程,例如处理所有用户输入的主进程。
放弃使用 Electron 的 webview 元素
虽然并非启用沙箱的必要条件,但我们借此机会重新审视了 Electron webview 标签在 VS Code 中的使用,并将其替换为 iframe 标签,以便更紧密地与 Web 版 VS Code 的工作方式保持一致。这两个标签的相似之处在于,它们都允许工作台托管来自扩展的非信任代码,同时隔离工作台免受运行这些代码的影响。例如,当您打开 Markdown 文件的预览时,内容会在这样一个元素中渲染,该元素由内置的 Markdown 扩展提供。
在大多数情况下,我们只需要将 webview
标签替换为 iframe
标签即可。但是,iframes
缺少一个功能:在内容中执行文本搜索并高亮显示结果的能力。此功能对于支持预览 Markdown 文档时的搜索至关重要。虽然 Chromium 内部实现了此功能,但并未将其作为 Web API 导出供使用。我们进行了必要的更改,以便在 Electron 中暴露该 API,从而得以放弃所有 webview
元素的使用。
启用渲染进程复用
沙箱化渲染进程的一个性能优势是它们在 Electron 中的生命周期行为。传统上,每次导航到另一个 URL 时,渲染进程都会终止并重新启动。对于 VS Code 来说,这意味着更改工作区或重新加载窗口都会重新创建渲染进程,这在某些环境和设置中可能会很慢。
沙箱化渲染进程会保持活跃,即使在导航 URL 时也是如此。打开另一个工作区或重新加载当前工作区会快很多。然而,要实现这一点,需要使在渲染进程中运行的原生 Node.js 模块具备上下文感知能力。尽管我们最终将所有原生模块移出了渲染进程以启用沙箱,但我们仍希望尽早测试渲染进程复用,因此使我们所有的原生模块都具备了上下文感知能力。
整合所有改进
最后一步是通过用户设置有条件地启用沙箱模式。我们不希望为所有用户启用沙箱模式,而是希望它在我们的 Insiders 版本中有一段时间进行验证。通过 window.experimental.useSandbox 设置,沙箱在 Insiders 中默认启用,也可以在 Stable 版本中启用。
我们计划利用我们的实验基础设施在 2023 年初逐步将沙箱功能推广到我们的 Stable 版本。随着我们检查问题,这将使我们能够在越来越多的用户群中测试和验证沙箱模式。
实验阶段结束后,沙箱模式将默认对所有用户启用,非沙箱模式将被移除。后续迭代仍有一些工作计划,例如,我们希望将共享进程转换为实用进程,因为它是一个隐藏窗口,并使用了超出必要的资源。
这是一段非凡的旅程,只有在整个 VS Code 团队的帮助和激励下才得以实现。很高兴看到我们能够逐步发布这些更改,并为需要进程沙箱的新 Electron 版本做好准备。我们大大改进了进程架构,并更紧密地与 Web 模型对齐,为未来奠定了坚实的基础。
术语解释
Electron 是使桌面版 VS Code 能够在我们支持的所有平台(Windows、macOS 和 Linux)上运行的主要框架。它结合了Chromium 的浏览器 API、V8 JavaScript 引擎以及Node.js API,还有平台集成 API,用于构建跨平台桌面应用程序。
在本篇博客文章中,我们将把 Electron 进程沙箱简称为“沙箱”。
理解 Chromium 以及 Electron 提供的进程模型非常重要。在本篇博客文章中,我们频繁提及以下进程
- 主进程 - 应用程序的主要入口点。
- 渲染进程 - 用户可以交互的窗口。
虽然总是只有一个主进程,但每个打开的窗口都会创建一个渲染进程。您可以在 Electron 进程模型文档和这篇Chrome Developers 博客文章中了解有关进程模型的更多信息。
“共享进程”并非 Electron 特有的概念,而是 VS Code 的实现细节。它是一个启用了 Node.js 的隐藏 Electron 窗口,所有其他窗口都可以与其通信以执行复杂任务,例如扩展安装。
“扩展主机”是一个运行所有已安装扩展的进程,它与渲染进程隔离。每个打开的窗口都有一个扩展主机。
VS Code 的“工作台”窗口是用户与之交互以编辑文件、搜索或调试的主窗口。在这篇博文中,我们将其简称为“工作台”。其他窗口包括可以从帮助菜单访问的进程管理器和问题报告器。
我们使用术语“IPC”来指代进程间通信。IPC 是一种一个进程与另一个进程通信的方式。
我们发布一个名为“Insiders”的 VS Code 每夜构建版本,用于在一部分用户中测试最新的更改。VS Code 团队的每个人都使用 Insiders 版本,我们也希望您尝试一下并报告任何 问题。
愉快编程!
Benjamin Pasero, @BenjaminPasero