将 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 依赖项这样大的更改,存在引入回归和错误的风险。以前在一个进程中运行的代码将不得不拆分并在多个进程中运行。原生 Node 模块因此无法进行 Web 打包的也必须移出。某些全局对象,例如 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://<磁盘文件路径>
)。作为沙盒化工作的一部分,我们重新审视了这种方法,因为它具有严重的安全隐患。Chromium 对本地文件协议做出的一些安全假设不如 HTTPS 协议严格。例如,严格的源检查不适用于本地文件协议 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。通过将代码迁移到此环境中,我们能够逐步使代码做好沙盒准备。
在下面的截图中,编辑器中出现一个警告标记,表明来自浏览器目标环境的文件依赖于 Node.js 的 API。此警告将导致我们的构建失败,并防止意外将此代码推送到发布版本。
我们的进程浏览器和问题报告器实用程序是首批符合 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,因此我们为 Electron 贡献了一个新的工具进程 API。这个 API 使我们能够将扩展主机从渲染器进程中移出,并移到一个由主进程创建的工具进程中。通过使用消息端口,我们可以在渲染器和扩展主机之间直接通信,而不会影响任何其他进程,例如处理所有用户输入的主进程。
弃用 Electron webview 元素
尽管并非启用沙盒的必要条件,但我们借此机会重新审视了 VS Code 中 Electron webview 标签的使用,并将其替换为 iframe 标签,以更紧密地与 VS Code 在 Web 中的工作方式保持一致。这两个标签的相似之处在于它们都允许工作台托管来自扩展的不可信代码,同时将工作台与运行此代码的影响隔离开来。例如,当您打开 Markdown 文件的预览时,内容会在这种元素中渲染,由内置的 Markdown 扩展提供。
在大多数情况下,我们只需将 webview
标签替换为 iframe
标签即可。然而,iframe
缺少一个功能,即在内容中执行和突出显示文本搜索的能力。此功能对于在预览 Markdown 文档时进行搜索至关重要。虽然 Chromium 内部实现了此功能,但它并未作为 Web API 导出供使用。我们进行了必要的更改,以在 Electron 中公开该 API,并能够放弃所有 webview
元素的使用。
启用渲染器进程复用
沙盒化渲染器进程的一个性能优势在于它们在 Electron 中的生命周期行为。传统上,每当导航到另一个 URL 时,渲染器进程都会终止并重新启动。对于 VS Code 而言,这意味着更改工作区或重新加载窗口都会重新创建渲染器进程,这在某些环境和设置中可能会很慢。
沙盒化的渲染器进程即使在导航 URL 时也保持活动状态。打开另一个工作区或重新加载当前工作区会快得多。然而,要实现这一点,需要使在渲染器进程中运行的原生 Node.js 模块具备上下文感知能力。尽管我们最终将所有原生模块移出了渲染器进程以启用沙盒化,但我们仍希望尽早测试渲染器进程的复用,因此使我们所有的原生模块都具备了上下文感知能力。
整合所有要素
最后一步是通过用户设置有条件地启用沙盒模式。我们不希望为所有用户启用沙盒模式,而是希望给它一些时间在我们的 Insiders 预览版中进行验证。通过 window.experimental.useSandbox 设置,沙盒在 Insiders 预览版中默认启用,并且可以在稳定版中启用。
我们计划在 2023 年初利用我们的实验基础设施,逐步将沙盒功能推广到我们的稳定版。这将使我们能够在检查问题的同时,在越来越多的用户群体上测试和验证沙盒模式。
一旦实验阶段结束,沙盒模式将默认对所有用户启用,并且非沙盒模式将被移除。仍有一些工作计划在后续迭代中进行,例如,我们希望将共享进程转换为一个工具进程,因为它是一个隐藏窗口,并且占用了超出必要的资源。
这是一段令人惊叹的旅程,只有在整个 VS Code 团队的帮助和激励下才得以实现。很高兴看到我们能够逐步推出这些更改,并为需要进程沙盒化的新 Electron 版本做好准备。我们大大改进了我们的进程架构,并与 Web 模型更紧密地对齐,为未来奠定了坚实的基础。
所用术语
Electron 是支持桌面版 VS Code 在我们所有支持的平台(Windows、macOS 和 Linux)上运行的主要框架。它将 Chromium 与浏览器 API、V8 JavaScript 引擎和 Node.js API,以及平台集成 API 结合起来,以构建跨平台桌面应用程序。
在这篇博客文章中,我们将 Electron 进程沙盒化简称为“沙盒”。
了解 Chromium 以及 Electron 提供的进程模型非常重要。在这篇博客文章中,我们经常提到以下进程
- 主进程 - 应用程序的主要入口点。
- 渲染器进程 - 用户可以与之交互的窗口。
虽然始终只有一个主进程,但每个打开的窗口都会创建一个渲染器进程。您可以在 Electron 的进程模型文档和这篇Chrome 开发者博客文章中了解更多关于进程模型的信息。
“共享进程”并非 Electron 特有,而是 VS Code 的一个实现细节。它是一个启用了 Node.js 的隐藏 Electron 窗口,所有其他窗口都可以与其通信以执行复杂任务,例如扩展安装。
“扩展主机”是一个进程,它运行所有已安装的扩展,并与渲染器进程隔离。每个打开的窗口都有一个扩展主机。
VS Code“工作台”窗口是用户用于编辑文件、搜索或调试的主窗口。在本博客文章中,我们将其简称为“工作台”。其他窗口是进程浏览器 (Process Explorer) 和问题报告器 (Issue Reporter),可以从帮助菜单中访问。
我们使用术语“IPC”来指代进程间通信。IPC 是一种一个进程与另一个进程通信的方式。
我们发布了一个名为“Insiders 预览版”的 VS Code 每夜构建版本,以便在部分用户中测试最新更改。VS Code 团队中的每个人都使用 Insiders 预览版,我们希望您也能尝试一下并报告任何问题。
编程愉快!
Benjamin Pasero, @BenjaminPasero