将 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”,而且还写入本地磁盘上的文件:

Electron 网页上的 HTML 和 Node.js 代码

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

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

通过在 Electron 中提供异步替代方案来删除 Node.js

下面的时间轴部分详细介绍了我们如何在渲染器进程中拥有全局vscode以及如何实现它。

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

我们是怎么到达那里的?

从渲染器进程中删除所有 Node.js 依赖项这样大的更改都会带来回归和错误的风险。以前在一个进程中运行的代码必须拆分并跨多个进程运行。原生的、因此无法进行 Web 打包的节点模块也必须移出。某些全局对象(例如 Node.js Buffer )必须替换为浏览器兼容的变体(例如Uint8Array )

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

2020年沙箱前的VS Code流程模型

我们很快决定要致力于进程沙箱,而不必交付单独的沙箱 VS Code 应用程序。我们希望逐步使 VS Code 渲染器进程沙箱就绪,然后在最后翻转开关。在过去的几年里,我们每月发布 VS Code 稳定版本,其中包含有助于实现沙箱目标的更改,但并未完全启用它。想象一下驾驶一架正在从根本上重建的飞机在空中飞行。在我们的案例中,用户大多不知道 VS Code 的更改。

我们的技术时间表:

接下来的部分将详细介绍沙箱在过去几年中是如何结合在一起的。主要任务是从渲染器进程中删除所有 Node.js 依赖项,但在此过程中出现了更多挑战,例如在各种 Node.js 子进程的帮助下找出高效的沙箱就绪 IPC 解决方案或为各个 Node.js 子进程找到新MessagePort主机我们可以从渲染器进程中派生出的进程。

在大多数情况下,主题的顺序遵循实际的时间线。为了保持每个部分的简短,我们链接到其他文档和教程,更详细地解释某个技术方面。尽管我们计划在 2020 年初开展这项工作,但遗漏一些之前有助于完成这项任务的工作是不公平的。让我们仔细看看……

站在巨人的肩膀上

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

在浏览器中运行的 VS Code for Web

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

支持 Node.js 和 Web 环境的 VSBuffer 实用程序类

但甚至在 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,但主脚本不能。因此,我们通过上下文桥向主脚本公开某些方法。在我们一开始使用的示例中,以下是如何将更新设置的方法从预加载脚本公开到主脚本中:

在 Electron 中将预加载脚本的方法公开到主脚本

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

Electron中涉及预加载脚本时的IPC流程

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

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

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 Web 的投资,我们已经拥有静态分析工具,可以阻止 Node.js 代码发送到 Web 版本。该工具定义了一组目标环境及其运行时要求。Buffer我们的工具可以检测并报告在不允许使用 Node.js 全局对象(例如 )、Node.js API 或节点模块的目标环境中的使用情况。对于沙箱工作,我们添加了一个新的目标环境electro-sandbox,它不允许使用任何 Node.js。通过将代码移至此环境中,我们能够逐渐使代码沙箱做好准备。

在下面的屏幕截图中,编辑器中出现一个警告标记,指示浏览器目标环境中的文件依赖于 Node.js 中的 API。该警告将导致我们的构建失败并防止意外将此代码推送到版本。

VS Code 中的警告,通知目标环境违规

我们的 Process Explorer 和 Issue Reporter 实用程序是最早符合电子沙箱目标要求的实用程序之一。在工作台窗口完成采用之前,我们就能够完全沙盒地运行这些窗口。

将进程移出渲染器

正如前面的主题已详细解释的那样,将 Node.js 功能片段转移到另一个进程并使用 IPC 来安排工作和接收结果可以是直接的。

然而,工作台中依赖于 Node.js 的一些组件更为复杂,特别是那些 fork 子进程的组件,例如:

  • 分机主机
  • 综合终端
  • 文件观看
  • 全文检索
  • 任务执行
  • 调试

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

对于分机主机,我们有更雄心勃勃的计划。我们稍后会在其自己的部分中介绍这些更改,因为它需要向 Electron 添加新的“实用程序进程”API。

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

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

2022 年末沙箱后的 VS Code 流程模型

调整 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。然而,缺少一项功能iframes,即在内容中执行和突出显示文本搜索的能力。此功能对于支持在预览 Markdown 文档时进行搜索至关重要。虽然 Chromium 在内部实现了此功能,但它并未导出为 Web API 来使用。我们进行了必要的更改以在 Electron 中公开 API,并且能够删除webview元素的所有用法。

启用渲染器进程重用

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

即使在浏览 URL 时,沙盒渲染器进程也会保持活动状态。打开另一个工作区或重新加载当前工作区要快捷得多。然而,要实现这一点,需要使在渲染器进程上下文中运行的本机 Node.js 模块具有感知能力。尽管我们最终将所有本机模块移出渲染器进程以启用沙箱,但我们仍然希望尽早测试渲染器进程重用,从而使所有本机模块上下文感知。

把它们放在一起

最后一步是通过用户设置有条件地启用沙箱模式。我们不想为所有用户启用沙盒模式,而是给它一些时间在我们的Insiders版本中进行验证。通过window.experimental.useSandbox设置,沙箱在 Insiders 中默认启用,并且可以在 Stable 中启用。

我们计划在 2023 年初使用我们的实验基础设施逐步向我们的稳定版推出沙盒支持。这将使我们能够在检查问题时在越来越多的用户上测试和验证沙盒模式。

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

这是一次令人惊奇的旅程,只有在整个 VS Code 团队的帮助和激励下才能实现。很高兴看到我们可以增量地发布这些更改,并为需要进程沙箱的新 Electron 版本做好准备。我们能够极大地改进我们的流程架构,并与网络模型更加紧密地结合,为未来奠定了坚实的基础。

使用的术语

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

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

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

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

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

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

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

VS Code“工作台”窗口是用户交互以编辑文件、搜索或调试的主窗口。在这篇博文中,我们将其简称为“工作台”。其他窗口是 Process Explorer 和 Issue Reporter,可以从“帮助”菜单访问。

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

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

快乐编码!

本杰明·斯帕罗,@BenjaminPasero