将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”,还会在本地磁盘上写入一个文件:
负责向用户展示网页的Electron进程被称为渲染器进程。为渲染器进程启用沙盒模式可以减少其功能以提高安全性,并更符合Web模型:虽然仍然允许使用HTML和JavaScript,但不允许使用Node.js。渲染器进程中需要访问系统资源的组件将不得不委托给另一个未沙盒化的进程。
下面的代码不再依赖于Node.js,而是使用了一个vscode
全局变量,该变量提供了更新设置的功能。该方法的实现涉及向另一个可以访问Node.js的进程发送消息。因此,它也不再同步执行,而是异步执行:
我们如何在渲染器进程中拥有vscode
全局变量以及它是如何实现的,在下面的时间线部分中有详细说明。
阻止Node.js在渲染器进程中的使用是Electron的一个鼓励的安全建议。我们过去曾遇到过安全问题,攻击者能够从渲染器进程执行任意的Node.js代码。沙盒化的渲染器进程大大降低了这些攻击的风险。
我们是如何到达那里的?
像从渲染进程中移除所有Node.js依赖这样的大规模更改,存在回归和错误的风险。以前在一个进程中运行的代码将不得不拆分并在多个进程中运行。原生的Node模块因此无法进行web打包,也必须移出。某些全局对象,如Node.js的Buffer,将不得不被浏览器兼容的变体如Uint8Array所替代。
下图展示了我们在沙盒工作开始之前的过程架构。如您所见,大多数进程是从渲染器进程派生的Node.js子进程(绿色显示)。大多数(进程间通信)IPC是通过Node.js套接字实现的,渲染器进程是Node.js API的主要客户端——例如用于读取和写入文件。
我们迅速决定,我们希望在不需发布一个单独的沙盒化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的依赖。
移除对Node.js的依赖意味着需要找到替代方案。例如,我们对Node.js的Buffer
类型的依赖被替换为VSBuffer等效物,在浏览器环境中会回退到Uint8Array
。我们还能够打包一些Node.js模块(oniguruma, iconv-lite)以在Web环境中运行。
但即使在VS Code for the Web成为现实之前,我们已经启用了对远程开发的支持,这允许在远程主机上编辑源代码,例如通过SSH连接(后来甚至支持了GitHub Codespaces)。对于远程开发,我们必须实现一个解决方案,其中VS Code的面向用户界面的部分在本地运行,而实际的文件操作在远程机器上运行。这种模型也适用于沙盒化的工作台,其中特权操作必须在不同的进程中运行。在这两种情况下,渲染器进程通过IPC与特权主机通信以执行操作。
启用来自渲染器的通信通道
当渲染进程无法使用Node.js时,必须将工作委托给另一个可以使用Node.js的进程。在Web环境中,一个解决方案可能是依赖HTTP方法,其中服务器接受请求。然而,对于桌面应用程序来说,这似乎不是最佳解决方案,因为出于安全原因,防火墙可能会阻止在端口上运行本地服务器。
Electron 提供了将预加载脚本注入到渲染器进程中的能力,这些脚本在主脚本执行之前执行。这些脚本可以访问 Electron 自己的IPC 机制。预加载脚本可以通过上下文桥接 API 丰富渲染器主脚本可用的 API。虽然预加载脚本可以直接使用 Electron 的 IPC,但主脚本不能。因此,我们通过上下文桥接向主脚本暴露了某些方法。在我们最初使用的示例中,以下是如何将更新设置的方法从预加载脚本暴露到主脚本的方式:
预加载脚本是我们将特权代码与非特权代码分离的基本构建块。例如,写入磁盘上的文件意味着带有新内容的IPC消息将从主脚本传递到预加载脚本,然后从那里传递到具有访问Node.js权限的主进程。
通过消息端口进行快速进程间通信
随着预加载脚本的引入,我们有了一个让渲染进程与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并可以使用它直接发送消息。
更改渲染器的原点
在网页浏览器中,您输入一个URL,内容会被加载并展示。在Electron中,您不需要输入URL,而是由应用程序决定加载和展示哪些内容。因此,当您打开VS Code时,窗口会加载一个预先配置好的URL来显示工作台的内容。
对于VS Code,此URL曾使用本地文件协议指向磁盘上的实际文件进行加载(file://<磁盘上文件的路径>
)。作为沙箱化工作的一部分,我们重新审视了这种方法,因为它具有严重的安全隐患。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版本。这些工具定义了一组目标环境及其运行时要求。我们的工具可以检测并报告在不允许使用Node.js的目标环境中使用Node.js全局对象(如Buffer
)、Node.js API或node模块的情况。为了进行沙盒化工作,我们添加了一个新的目标环境electron-sandbox,该环境不允许使用任何Node.js。通过将代码迁移到这个环境中,我们能够逐步使代码准备好沙盒。
在下面的截图中,编辑器中出现了一个警告标记,表明来自browser目标环境的文件依赖于Node.js的API。这个警告将导致我们的构建失败,并防止意外将此代码推送到发布版本中。
我们的进程资源管理器和问题报告工具是最早符合electron-sandbox目标要求的工具之一。我们能够在工作台窗口完成采用之前,完全在沙盒中运行这些窗口。
将进程移出渲染器
正如前面的主题详细解释的那样,将Node.js的功能部分转移到另一个进程,并使用IPC来调度工作和接收结果,可以非常直接。
然而,工作台中一些依赖于Node.js的组件更为复杂,特别是那些会创建子进程的组件,例如:
- 扩展主机
- 集成终端
- 文件监视
- 全文搜索
- 任务执行
- 调试
鉴于VS Code可以在远程场景中运行,我们已经有了远程执行一些任务的机制,即:搜索、调试和任务执行。这些组件可以在扩展主机进程中运行,该进程自然运行在代码所在的本地。因此,即使VS Code在没有附加远程的情况下本地运行时,我们也能够将这些子进程的所有权从渲染器进程转移到扩展主机。
对于扩展主机,我们有更雄心勃勃的计划。我们在后面的部分中介绍了这些变化,因为它需要在Electron中添加一个新的“utility process” API。
集成的终端和文件监视功能已移至共享进程的子进程。任何需要文件监视或集成终端的窗口将通过消息端口与共享进程通信以获取这些服务。
下图展示了我们在2022年底的进程架构,当时我们已经在渲染进程中启用了沙箱。所有Node.js进程都已迁移为共享进程的子进程或主进程的实用程序进程。消息端口用于高效的直接进程间通信,而不会给主进程增加负担。
调整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 使我们能够将扩展主机从渲染进程移出,并放入一个由主进程创建的实用进程中。通过使用消息端口,我们可以在渲染进程和扩展主机之间直接通信,而不会影响其他进程,例如处理所有用户输入的主进程。
脱离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版本做好准备。我们能够极大地改进我们的进程架构,并与Web模型更加紧密地保持一致,为未来奠定了坚实的基础。
使用的术语
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 的“工作台”窗口是用户用来编辑文件、搜索或调试的主窗口。在这篇博客文章中,我们简称为“工作台”。其他窗口包括可以从帮助菜单访问的进程资源管理器和问题报告器。
我们使用术语“IPC”来指代进程间通信。IPC是一种进程与另一个进程通信的方式。
我们发布了一个名为“Insiders”的VS Code夜间版本,以在一部分用户中测试最新的更改。VS Code团队的每个人都使用Insiders版本,我们希望您也能尝试并报告任何问题。
编程快乐!
本杰明·帕塞罗, @BenjaminPasero