尝试以扩展 VS Code 中的代理模式!

将 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",还向本地磁盘写入一个文件。

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 或 node 模块的情况。为了进行沙盒化工作,我们添加了一个新的目标环境 **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 元素

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

在大多数情况下,我们能够直接用 `iframe` 标签替换 `webview` 标签。然而,`iframes` 缺少一个功能,即在内容中执行和高亮显示文本搜索的能力。这个功能对于在预览 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 的“工作台”窗口是用户与之交互以编辑文件、搜索或调试的主窗口。在这篇博文中,我们将其简称为“工作台”。其他窗口是进程资源管理器和问题报告器,可以从**帮助**菜单访问。

我们使用术语“IPC”来指代进程间通信。IPC 是一种让一个进程与另一个进程通信的方式。

我们发布一个名为“Insiders”的 VS Code 每夜构建版本,用于在一部分用户中测试最新的变更。VS Code 团队的每个人都使用 Insiders 版本,我们希望您也能尝试并报告任何问题

编码愉快!

Benjamin Pasero, @BenjaminPasero