🚀 在 VS Code 中

将 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)替换。

下图显示了沙盒工作开始之前的进程架构。如您所见,大多数进程是从渲染器进程 fork 的 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 解决方案,或者为我们可以从渲染器进程 fork 的各种 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>)。作为沙盒工作的一部分,我们重新审视了这种方法,因为它具有严重的安全隐患。与 HTTPS 协议相比,Chromium 对本地文件协议做出了一些安全性假设,这些假设不太严格。例如,严格的来源检查不适用于本地文件协议 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 全局对象(例如 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 的组件更为复杂,特别是那些 fork 子进程的组件,例如

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

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

对于扩展主机,我们有更宏伟的计划。我们稍后将在专门的 章节中介绍这些更改,因为它需要在 Electron 中添加新的“utility process”API。

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

下图显示了我们在 2022 年底启用渲染器进程中的沙盒后的进程架构。所有 Node.js 进程都已转移为共享进程的子进程或主进程的 utility process。消息端口用于高效的进程到进程直接通信,而不会给主进程带来负担。

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 贡献了一个新的 utility process API。此 API 使我们能够将扩展主机从渲染器进程移动到从主进程创建的 utility process。使用消息端口,我们可以在渲染器和扩展主机之间直接通信,而不会影响任何其他进程,例如处理所有用户输入的主进程。

移除 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 中默认启用,并且可以在 Stable 中启用。

我们计划使用我们的实验基础设施在 2023 年初逐步将沙盒启用推广到我们的 Stable 版本。这将使我们能够在越来越多的用户中测试和验证沙盒模式,同时检查问题。

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

这是一段令人惊叹的旅程,只有在整个 VS Code 团队的帮助和激励下才有可能实现。很高兴看到我们可以逐步发布这些更改,并为需要进程沙盒的新 Electron 版本做好准备。我们能够极大地改进我们的进程架构,并更紧密地与 Web 模型保持一致,为未来创建坚实的基础。

使用的术语

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

在这篇博文中,我们将把 Electron 的 进程沙箱 简称为 “sandbox”。

理解 Chromium 以及 Electron 提供的进程模型非常重要。在这篇博文中,我们经常会提到以下进程:

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

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

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

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

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

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

我们发布了 VS Code 的每晚构建版本,称为 “Insiders”,以便在一部分用户上测试最新的更改。VS Code 团队的每个人都在使用 Insiders 版本,我们也希望您能尝试一下并报告任何 问题

编码快乐!

Benjamin Pasero,@BenjaminPasero