将 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 进程被称为渲染器(renderer)进程。为渲染器进程启用沙盒模式可以降低其功能以提高安全性,并更好地与 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 依赖项这样巨大的改动,伴随着回归和 Bug 的风险。以前在一个进程中运行的代码必须被拆分并跨多个进程运行。作为本地模块因而无法进行 Web 打包的 Node 模块也必须移出。某些全局对象,例如 Node.js Buffer,必须替换为浏览器兼容的变体,例如 Uint8Array

下图显示了沙盒工作开始前的进程架构。如您所见,大多数进程是从渲染器进程派生出来的 Node.js 子进程(绿色)。大部分 IPC(进程间通信)是通过 Node.js socket 实现的,并且渲染器进程是 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 年初开始考虑沙盒时,我们已经发布了能够在网页浏览器中运行的 VS Code 版本。您可以在浏览器中运行 vscode.dev 并亲身体验 Visual Studio Code for the Web。在创建网页版 VS Code 的过程中,我们已经学会了如何从工作台(VS Code 的主要用户界面窗口)中移除 Node.js 依赖项。

VS Code for Web running in the browser

移除对 Node.js 的依赖意味着寻找替代方案。例如,我们对 Node.js Buffer 类型的依赖被替换为了在浏览器环境中会回退到 Uint8ArrayVSBuffer 等效项。我们还成功打包了一些 Node.js 模块(onigurumaiconv-lite)以便在 Web 环境中运行。

VSBuffer utility class supporting both Node.js and web environments

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

启用来自渲染器的通信通道

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

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

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 socket 实现的。这样做的好处是主进程完全没有开销,因为它根本不参与通信。缺点是由于无法使用任何 Node.js API,Node.js socket 通信在沙盒渲染器中是不可能的。

消息端口 提供了一种强大的方式,通过在两个进程之间建立 IPC 通道来将它们连接起来。甚至完全沙盒化的渲染器进程也可以使用消息端口,因为它们作为 Web API 在浏览器中提供。用消息端口替换 Node.js socket 通信使我们能够拥有一个兼容沙盒的 IPC 解决方案,同时仍保留了无需让主进程参与的性能优势。

跨进程边界传递消息端口是 复杂的,特别是传递到带有预加载脚本的沙盒渲染器进程中。其顺序如下图所示

  • 共享进程创建消息端口 P1 和 P2,并保留 P1。
  • P2 通过 Electron IPC 发送到主进程。
  • 主进程将 P2 转发给请求的渲染器进程。
  • P2 最终到达该渲染器进程的预加载脚本中。
  • 预加载脚本将 P2 转发到渲染器主脚本中。
  • 主脚本接收到 P2 并可以使用它直接发送消息。

Message ports exchange between shared and renderer process in VS Code

更改渲染器的源

在网页浏览器中,您输入一个 URL,然后内容被加载并呈现。在 Electron 中,您不输入 URL,而是由应用程序为您决定加载和呈现哪些内容。因此,当您打开 VS Code 时,窗口会加载一个预配置的 URL 以显示工作台的内容。

对于 VS Code 来说,这个 URL 曾使用指向磁盘上实际文件的本地文件协议进行加载(file://<path to file on disk>)。作为沙盒工作的一部分,我们重新审视了这种方法,因为它具有严重的安全性影响。Chromium 对本地文件协议做出了一些安全性假设,与 HTTPS 协议相比不够严格。例如,严格的源检查不适用于本地文件协议 URL。

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

随着为我们所有渲染器进程引入自定义的 vscode-file 协议,我们得以放弃对 file 协议的所有使用。它被 配置 为表现得像 HTTPS,这意味着我们向网页版 VS Code 的实际工作方式迈进了一步。

调整我们的代码加载器

历史上,我们所有的 TypeScript 代码都被编译为 AMD 模块,并使用我们多年来维护的 自定义加载器进行加载。我们正计划抛弃 AMD 并拥抱 ESM,但这项工作尚处于 早期阶段

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

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

助力采用的工具

既然我们已经有了一种在启用沙盒的情况下运行 VS Code 的方法,我们就希望投资于工具,以便更轻松地将依赖 Node.js 的源代码过渡到“为沙盒做好准备”的代码。鉴于我们在网页版 VS Code 上的投入,我们已经有了静态分析工具,可以阻止 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 socket 实现的。每个窗口有一个扩展宿主进程,并且扩展可以根据需要自由派生任意多的子进程。

我们曾考虑过像文件监听和集成终端一样将扩展宿主移入我们的共享进程,但觉得我们应该借此机会构建一些更灵活的东西,不再需要隐藏窗口作为宿主。

为此,我们需要一个健壮且可扩展的解决方案,它既能在沙盒渲染器中工作,又能保留大部分当前的行为:

  • 支持派生子进程的隔离进程
  • 完全的 Node.js 支持
  • 使用消息端口与沙盒进程进行直接 IPC

当时,Electron 无法向我们提供支持这些要求的 API,因此我们向 Electron 贡献了一个新的 实用进程 API。这个 API 使我们能够将扩展宿主从渲染器进程中移出,放入由主进程创建的实用进程中。通过使用消息端口,我们可以在渲染器和扩展宿主之间直接通信,而不会影响任何其他进程,比如处理所有用户输入的主进程。

摆脱 Electron webview 元素

虽然启用沙盒不一定非要这样做,但我们借此机会重新审视了 VS Code 中对 Electron webview 标签 的使用,并将其替换为 iframe 标签,以更贴近 VS Code 在网页中的工作方式。这两个标签的相似之处在于,它们都允许工作台托管来自扩展的不受信任的代码,同时将工作台与运行此代码的影响隔离开来。例如,当您打开 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 年初利用我们的实验基础设施,逐步向 Stable 版本推出沙盒启用功能。这将允许我们在检查问题时,在越来越多的用户中测试和验证沙盒模式。

一旦实验阶段结束,沙盒模式将默认对所有用户启用,并且非沙盒模式将被移除。后续迭代还计划了一些其他工作,例如,我们希望将共享进程转换为实用进程,因为它是一个隐藏窗口,并且使用了比实际需要更多的资源。

这是一段精彩的历程,如果没有整个 VS Code 团队的帮助和动力,这是不可能实现的。很高兴看到我们可以增量发布这些更改,并为需要进程沙盒的新 Electron 版本做好准备。我们大大改善了我们的进程架构,并更紧密地与 Web 模型保持一致,为未来奠定了坚实的基础。

使用的术语

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

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

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

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

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

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

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

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

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

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

祝编码愉快!

Benjamin Pasero, @BenjaminPasero

English 한국어 中文(简体) 中文(繁體)
© . This website operates independently and is not affiliated with or endorsed by Microsoft. All brand names, logos, and trademarks are the property of their respective owners.