现已推出!阅读 10 月份的新功能和修复。

将 VS Code 迁移到进程沙盒

安全性和 VS Code 架构的双赢

2022 年 11 月 28 日,作者:Benjamin Pasero,@BenjaminPasero

Electron 渲染器进程中启用 沙盒 是安全可靠的 Electron 应用程序(如 Visual Studio Code)的关键要求。沙盒通过限制对大多数系统资源的访问来减少恶意代码造成的损害。在这篇博文中,我们将详细介绍我们如何成功地在 VS Code 中启用进程沙盒,这是一段我们 从 2020 年初开始 并计划在 2023 年初完成的旅程。为了帮助理解进程沙盒的挑战,这篇博文还描述了 VS Code 进程模型的详细信息,以及它在此旅程中是如何演变的。

这是一个团队合作,因为几乎所有 VS Code 组件都需要进行基本架构更改以及代码修改。VS Code 进程架构已全面改版,在此过程中得到了显著加强。我们重点介绍了沿途的主要里程碑,希望这些里程碑能为他人提供宝贵的经验教训。在过去的几个月里,进程沙盒模式已成功运行于 VS Code Insiders,使我们能够获得有关此更改影响的反馈。如果您发现任何 问题、有关于如何改善体验的建议,或者有任何一般性问题,请不要犹豫 与我们联系

如果您不熟悉 VS Code、Electron 或沙盒,您可能需要先查看博文末尾的 术语 部分。您将在其中找到对所用术语的解释以及指向背景资料的链接。

简而言之,进程沙盒

长期以来,Electron 允许在 HTML 和 JavaScript 中直接使用 Node.js API。以下代码段提供了一个简单的网页示例,该网页不仅向用户打印“Hello World”,而且还写入本地磁盘上的文件

HTML and Node.js code on a web page in Electron

负责向用户呈现网页的 Electron 进程称为 **渲染器** 进程。启用渲染器进程的沙盒模式会降低其功能,从而提高安全性,并更符合 Web 模型:虽然仍然允许使用 HTML 和 JavaScript,但不允许使用 Node.js。渲染器进程中需要访问系统资源的组件必须委托给另一个未沙盒化的进程。

以下代码不再依赖 Node.js,而是使用一个 vscode 全局变量来提供更新设置的功能。该方法的实现涉及向另一个具有 Node.js 访问权限的进程发送消息。因此,它也不再同步执行,而是异步执行

Removing Node.js by providing an asynchronous alternative in Electron

我们在渲染器进程中如何获得 vscode 全局变量,以及它是如何实现的,将在下面的 时间线 部分中详细介绍。

阻止渲染器进程使用 Node.js 是 Electron 的一项鼓励性 安全建议。我们过去曾遇到过安全问题,攻击者能够从渲染器进程执行任意的 Node.js 代码。沙盒化的渲染器进程大大降低了这些攻击的风险。

我们是如何做到这一点的?

像从渲染器进程中移除所有 Node.js 依赖项这样的大规模更改存在出现回归和错误的风险。以前在一个进程中运行的代码必须被拆分并在多个进程中运行。不能进行 Web 打包的本机 Node 模块也必须移出。某些全局对象(如 Node.js Buffer)将不得不替换为与浏览器兼容的变体(如 Uint8Array)。

下图显示了沙盒工作开始之前的进程架构。如您所见,大多数进程都是从渲染器进程派生的 Node.js 子进程(以绿色显示)。大多数(进程间通信)IPC 是通过 Node.js 套接字实现的,渲染器进程是 Node.js API 的主要客户端,例如读取和写入文件。

VS Code process model before sandboxing in 2020

我们很快决定,我们希望在不发布独立的沙盒化 VS Code 应用程序的情况下进行进程沙盒化。我们希望逐步使 VS Code 渲染器进程准备好沙盒化,然后在最后切换开关。在过去的几年中,我们每月都会发布 VS Code 的稳定版本,这些版本中包含有助于沙盒目标的更改,但不会完全启用它。想象一下驾驶一架正在彻底重建的飞机,而它还在空中飞行。就我们而言,用户大多没有意识到 VS Code 的变化。

我们的技术时间线

接下来的部分将详细介绍沙盒化在过去几年是如何实现的。主要任务是从渲染器进程中移除所有 Node.js 依赖项,但在此过程中出现了更多挑战,例如找出使用 MessagePort 的高效沙盒就绪 IPC 解决方案,或者为我们能够从渲染器进程派生的各种 Node.js 子进程找到新的主机。

在大多数情况下,主题顺序遵循实际时间线。为了使每个部分简明扼要,我们会链接到其他文档和教程,这些文档和教程更详细地解释了某个技术方面。尽管我们在 2020 年初就计划了这项工作,但忽略掉一些有助于这项工作的先前工作是不公平的。让我们仔细看看...

站在巨人的肩膀上

当我们在 2020 年初开始考虑沙盒化时,我们已经发布了可以在 Web 浏览器中运行的 VS Code 版本。您可以在浏览器中运行 vscode.dev,并亲眼看看 Visual Studio Code for the Web 的实际运行情况。在创建 VS Code 的 Web 版本时,我们学习了如何从工作台(VS Code 主用户界面窗口)中移除 Node.js 依赖项。

VS Code for Web running in the browser

移除对 Node.js 的依赖意味着找到替代方案。例如,我们对 Node.js Buffer 类型的依赖被替换为一个 VSBuffer 等价物,该等价物将在浏览器环境中回退到 Uint8Array。我们还能够打包一些 Node.js 模块(onigurumaiconv-lite)以便在 Web 环境中运行。

VSBuffer utility class supporting both Node.js and web environments

但即使在 VS Code for the Web 成为现实之前,我们就已经启用了对 远程开发 的支持,该功能允许在远程主机上编辑源代码,例如通过 SSH 连接(后来甚至为 GitHub Codespaces 提供了支持)。对于远程开发,我们必须实现一种解决方案,使 VS Code 的面向 UI 的部分在本地运行,而实际的文件操作在远程计算机上运行。此模型也适用于沙盒化工作台,其中特权操作必须在不同的进程中运行。在这两种情况下,渲染器进程都通过 IPC 与特权主机通信以执行操作。

启用从渲染器到主进程的通信通道

当渲染器进程无法使用 Node.js 时,必须将工作委托给另一个可以使用 Node.js 的进程。在 Web 上下文中,一种解决方案可能是依赖 HTTP 方法,其中服务器接受请求。但是,对于桌面应用程序,这似乎不是最佳解决方案,因为在桌面应用程序中,运行在端口上的本地服务器可能会因安全原因而被防火墙阻止。

Electron 提供了将 预加载脚本 注入渲染器进程的功能,这些脚本在主脚本执行之前执行。这些脚本可以访问 Electron 自身的 IPC 机制。预加载脚本可以通过 上下文桥 API 丰富渲染器主脚本可用的 API。虽然预加载脚本可以直接使用 Electron 的 IPC,但主脚本却不能。因此,我们通过上下文桥将某些方法暴露给主脚本。在我们开始时使用的示例中,以下是从预加载脚本到主脚本公开更新设置方法的方式

Exposing a method from preload script to the main script in Electron

预加载脚本是我们将特权代码与非特权代码分离的基本构建块。例如,写入磁盘上的文件意味着包含新内容的 IPC 消息将从主脚本传播到预加载脚本,然后从预加载脚本传播到具有 Node.js 访问权限的主进程。

IPC flow when preload scripts are involved in Electron

通过消息端口实现快速进程间通信

随着预加载脚本的引入,我们提供了一种机制,让渲染器进程与 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,并可以使用它直接发送消息。

Message ports exchange between shared and renderer process in VS Code

更改渲染器的来源

在 Web 浏览器中,你输入一个 URL,然后加载并显示内容。在 Electron 中,你不需要输入 URL,而是由应用程序决定要加载和显示哪些内容。因此,当你打开 VS Code 时,会加载一个窗口,其中包含一个预配置的 URL 来显示工作台的内容。

对于 VS Code,这个 URL 使用指向磁盘上实际文件的本地文件协议来加载(file://<path to file on disk>)。作为沙盒化工作的一部分,我们重新审视了这种方法,因为它存在严重的安全性隐患。Chromium 对本地文件协议做出了某些安全性假设,这些假设比 HTTPS 协议的假设更宽松。例如,对本地文件协议 URL 不会应用严格的来源检查。

使用 Electron,你可以注册 自定义协议,这些协议可用于将内容加载到渲染器进程中。可以配置自定义协议,使其在安全性方面与 HTTPS 协议的行为相同。我们使用这种方法来避免必须运行本地 Web 服务器来提供内容。

通过为我们所有渲染器进程引入自定义 vscode-file 协议,我们能够放弃所有对文件协议的使用。它 配置 为与 HTTPS 行为一致,这意味着我们更接近 VS Code for the Web 的实际工作方式。

调整我们的代码加载器

从历史上看,我们所有的 TypeScript 代码都被编译成 AMD 模块,并使用一个 自定义加载器 加载,我们多年来一直在维护它。我们计划从 AMD 转向 ESM,但这项工作还处于 早期阶段

我们的代码加载器通过探测一些定义良好的变量来识别实际运行环境,从而支持 Node.js 和 Web 环境。沙盒化的渲染器本质上类似于 Web 环境,因此我们的加载器只需做很少的更改就能支持沙盒。

一旦这些更改到位,我们就能运行启用沙盒模式的 VS Code 早期版本。然而,由于我们还没有将渲染器进程从它的 Node.js 依赖项中解放出来,因此只显示了一个空白页面,以及输出到控制台的错误。

帮助采用沙盒的工具

现在我们有了启用沙盒运行 VS Code 的方法,我们想投资于工具,让从依赖 Node.js 的源代码过渡到“已准备就绪的沙盒代码”变得更容易。鉴于我们在 VS Code for the Web 上的投入,我们已经拥有了静态分析工具,这些工具可以阻止 Node.js 代码被运送到 Web 版本。该工具定义了一组 目标环境 及其运行时要求。我们的工具可以检测并在不允许使用 Node.js 的目标环境中报告使用 Node.js 全局对象(如 Buffer)、Node.js API 或节点模块的情况。为了进行沙盒化工作,我们添加了一个新的目标环境 electron-sandbox,它不允许使用任何 Node.js。通过将代码移到这个环境中,我们能够逐步使代码准备好沙盒。

在下面的屏幕截图中,编辑器中出现了一个警告标记,表示来自 browser 目标环境的文件依赖于 Node.js 的 API。该警告会导致我们的构建失败,并阻止意外地将此代码推送到发行版中。

A warning in VS Code informing about a target environment violation

我们的进程资源管理器和问题报告工具是最早符合 electron-sandbox 目标要求的工具之一。我们能够在工作台窗口完成采用之前,就完全沙盒化地运行这些窗口。

将进程从渲染器中移出

如前文所述,将 Node.js 功能的一部分移到另一个进程,并使用 IPC 来安排工作并接收结果可能很直接。

然而,工作台中一些依赖 Node.js 的组件更复杂,尤其是那些派生子进程的组件,例如:

  • 扩展程序宿主
  • 集成终端
  • 文件监视
  • 全文搜索
  • 任务执行
  • 调试

鉴于 VS Code 可以运行在远程场景中,我们已经有了在远程执行某些任务的机制,即:搜索、调试和任务执行。这些组件可以在扩展程序宿主进程中运行,扩展程序宿主进程自然运行在代码所在的位置。因此,即使 VS Code 在本地运行且没有连接远程服务器,我们也能将这些子进程的所有权从渲染器进程移到扩展程序宿主进程。

对于扩展程序宿主,我们有更雄心勃勃的计划。我们在后文的 部分 中介绍了这些更改,因为它需要在 Electron 中添加一个新的“实用程序进程”API。

集成终端和文件监视已移至共享进程的子进程。任何需要文件监视或集成终端的窗口都会通过消息端口与共享进程通信,以获取这些服务。

下图显示了我们 2022 年末的进程架构,当时我们在渲染器进程中启用了沙盒。所有 Node.js 进程都已移至共享进程的子进程或来自主进程的实用程序进程。消息端口用于在没有影响主进程的情况下进行有效的直接进程间通信,主进程负责处理所有用户输入。

VS Code process model after sandboxing in late 2022

调整 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 元素

虽然启用沙盒并不一定需要这样做,但我们借此机会重新审视了 Electron 中 webview 标签 的使用,并将其替换为 iframe 标签,以便更紧密地与 VS Code 在 Web 中的工作方式保持一致。这两个标签的相似之处在于,它们都允许工作台托管来自扩展程序的不信任代码,同时将工作台与运行此代码的影响隔离开来。例如,当你打开 Markdown 文件的预览时,其内容将在一个这样的元素中呈现,该元素由内置的 Markdown 扩展程序提供。

在大多数情况下,我们能够直接用iframe标签替换webview标签。然而,iframes缺少一项功能,即在内容中执行和突出显示文本搜索的功能。这项功能对于在预览 Markdown 文档时支持搜索 Markdown 文档至关重要。虽然 Chromium 在内部实现了这项功能,但它没有作为 Web API 导出供使用。我们做出了必要的更改,以便在 Electron 中公开该 API,并且我们能够放弃所有对webview元素的使用。

启用渲染器进程重用

沙盒渲染器进程的一个性能优势是它们在 Electron 中的生命周期行为。传统上,渲染器进程会在每次导航到另一个 URL 时终止并重新启动。对于 VS Code 来说,这意味着更改工作区或重新加载窗口会重新创建渲染器进程,这在某些环境和设置中可能很慢。

沙盒渲染器进程即使在导航 URL 时也会保持活动状态。打开另一个工作区或重新加载当前工作区要快得多。但是,为了使这能够工作,需要使在渲染器进程中运行的原生 Node.js 模块上下文感知。即使我们最终将所有原生模块移出了渲染器进程以启用沙盒,我们仍然希望尽早测试渲染器进程重用,因此我们使所有原生模块都成为上下文感知的。

将它们整合在一起

最后一步是通过用户设置有条件地启用沙盒模式。我们不想为所有用户启用沙盒模式,而是希望让它在我们的内部版中验证一段时间。使用window.experimental.useSandbox设置,沙盒在内部版中默认启用,并且可以在稳定版中启用。

我们计划利用我们的实验基础设施,在 2023 年初将沙盒启用逐步推广到我们的稳定版。这将使我们能够在一组不断增长的用户上测试和验证沙盒模式,同时检查是否存在问题。

实验阶段结束后,沙盒模式将默认对所有用户启用,而无沙盒模式将被移除。我们仍然计划在以后的迭代中进行一些工作,例如,我们希望将共享进程转换为实用程序进程,因为它是一个隐藏的窗口,并且使用比必要更多的资源。

这是一段非凡的旅程,如果没有整个 VS Code 团队的帮助和激励,这将是不可能实现的。很高兴看到我们可以逐步发布这些更改,并为需要进程沙盒的新的 Electron 版本做好准备。我们能够显著改进我们的进程架构,并更紧密地与 Web 模型保持一致,从而为未来奠定了坚实的基础。

使用的术语

Electron 是使 VS Code for Desktop 能够在我们所有支持的平台(Windows、macOS 和 Linux)上运行的主要框架。它将Chromium 与浏览器 API、V8 JavaScript 引擎和Node.js API,以及平台集成 API组合起来,以构建跨平台桌面应用程序。

在本博文中,我们将简单地将 Electron 的进程沙盒称为“沙盒”。

了解 Chromium 以及 Electron 提供的进程模型非常重要。在本博文中,我们经常提到以下进程

  • 主进程 - 应用程序的主入口点。
  • 渲染器进程 - 用户可以与之交互的窗口。

虽然始终只有一个主进程,但每个打开的窗口都会创建渲染器进程。您可以在 Electron 的进程模型文档和这篇Chrome 开发人员博客文章中了解有关进程模型的更多信息。

“共享进程”并非 Electron 独有,而是 VS Code 的实现细节。它是一个隐藏的 Electron 窗口,启用了 Node.js,所有其他窗口都可以与它通信以执行复杂的任务,例如扩展安装。

“扩展宿主”是一个进程,它运行所有已安装的扩展,与渲染器进程隔离。每个打开的窗口都包含一个扩展宿主。

VS Code 的“工作台”窗口是用户用来编辑文件、搜索或调试的主窗口。在本博文中,我们将它简单地称为“工作台”。其他窗口是进程资源管理器和问题报告器,可以通过**帮助**菜单访问。

我们使用“IPC”来指代进程间通信。IPC 是一种进程相互通信的方式。

我们发布了一个名为“内部版”的 VS Code 夜间版本,以便在一个子集的用户上测试最新的更改。VS Code 团队的每个人都使用内部版,我们希望您也能尝试使用它,并报告任何问题

编码愉快!

Benjamin Pasero,@BenjaminPasero