🚀 在 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 依賴項這樣大的變更,會帶來回歸和錯誤的風險。先前在一個程序中運行的程式碼將必須分割並跨多個程序運行。本機 Node 模組(因此無法進行 Web 打包)也必須移出。某些全域物件(例如 Node.js Buffer)將必須替換為瀏覽器相容的變體,例如 Uint8Array

下圖顯示了沙箱化工作開始之前的程序架構。如您所見,大多數程序都是從渲染器程序分叉出來的 Node.js 子程序(綠色)。大多數(跨程序通訊)IPC 是透過 Node.js sockets 實作的,而渲染器程序是 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 提供了將 preload scripts 注入到渲染器程序中的能力,這些腳本在主腳本執行之前執行。這些腳本可以存取 Electron 自己的 IPC 機制。Preload scripts 可以透過 context bridge API 豐富渲染器主腳本可用的 API。雖然 preload script 可以直接使用 Electron 的 IPC,但主腳本不能。因此,我們透過 context bridge 向主腳本公開了某些方法。在我們一開始使用的範例中,以下是如何從 preload script 將更新設定的方法公開到主腳本

Exposing a method from preload script to the main script in Electron

Preload scripts 是我們將特權程式碼與非特權程式碼分離的基本建構區塊。例如,寫入磁碟上的檔案意味著包含新內容的 IPC 訊息將從主腳本傳輸到 preload script,然後從那裡傳輸到有權存取 Node.js 的主程序。

IPC flow when preload scripts are involved in Electron

透過訊息埠進行快速跨程序通訊

透過引入 preload scripts,我們有了一種讓渲染器程序與 Electron 主程序通訊以排程工作的方式。但是,在 Electron 應用程式中,至關重要的是不要讓主程序負擔過重的工作,因為它也是負責處理使用者輸入(例如來自鍵盤和滑鼠)的程序。繁忙的主程序可能會導致使用者介面無回應。

這是我們以前見過的問題。即使在從事沙箱化工作之前,我們也對將效能密集型程式碼卸載到背景程序(VS Code 共享程序)感興趣。此程序是一個隱藏視窗,所有工作台視窗和主程序都可以與之通訊。例如,當您安裝擴充功能時,會將請求發送到共享程序以執行整個操作。

但是,與共享程序的通訊是透過 Node.js sockets 實作的。這樣做的好處是主程序零負擔,因為它根本沒有參與通訊。缺點是在沙箱化的渲染器中無法進行 Node.js socket 通訊,因為您無法使用任何 Node.js API。

訊息埠提供了一種強大的方式,透過在兩個程序之間建立 IPC 通道來將它們相互連接。即使是完全沙箱化的渲染器程序也可以使用訊息埠,因為它們在瀏覽器中作為 Web API 提供。將 Node.js socket 通訊替換為訊息埠,使我們能夠擁有與沙箱相容的 IPC 解決方案,同時仍然保留了不讓主程序參與的效能方面。

跨程序邊界傳遞訊息埠是複雜的,尤其是在具有 preload scripts 的沙箱化渲染器程序中。順序如下圖所示

  • 共享程序建立訊息埠 P1 和 P2,並保留 P1。
  • P2 透過 Electron IPC 發送到主程序。
  • 主程序將 P2 轉發到請求的渲染器程序。
  • P2 最終會在該渲染器程序的 preload script 中。
  • preload script 將 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>)。作為沙箱化工作的一部分,我們重新審視了這種方法,因為它具有嚴重的安全性隱含。對於本機檔案協定 URL,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 新增新的「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 sockets 實作的。每個視窗有一個擴充功能主機程序,並且擴充功能可以自由地產生任意數量的子程序。

我們曾考慮將擴充功能主機移轉到我們的共享程序中,就像檔案監看器和整合式終端機一樣,但感覺我們應該藉此機會建立更靈活的東西,而不需要隱藏視窗作為主機。

為此,我們希望有一個穩健且可擴展的解決方案,它可以在沙箱化的渲染器中運作,但保留目前的大部分行為

  • 隔離程序,支援產生子程序
  • 完整的 Node.js 支援
  • 使用訊息埠進行與沙箱化程序的直接 IPC

當時,Electron 無法為我們提供支援這些要求的 API,因此我們向 Electron 貢獻了一個新的 utility process API。此 API 使我們能夠將擴充功能主機從渲染器程序移轉到從主程序建立的 utility process 中。使用訊息埠,我們可以在渲染器和擴充功能主機之間直接通訊,而不會影響任何其他程序,例如處理所有使用者輸入的主程序。

移出 Electron webview 元素

雖然不一定需要啟用沙箱,但我們藉此機會重新審視了在 VS Code 中使用 Electron webview tag 的情況,並將其替換為 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 年初逐步將沙箱啟用推廣到我們的 Stable 版本。這將使我們能夠在越來越多的使用者群體上測試和驗證沙箱模式,同時檢查問題。

一旦實驗階段結束,沙箱模式將預設為所有使用者啟用,並且將移除非沙箱模式。稍後的迭代版本仍有一些工作計劃,例如,我們希望將共享程序轉換為 utility process,因為它是一個隱藏視窗,並且使用的資源超出必要。

這是一趟令人驚嘆的旅程,只有在整個 VS Code 團隊的幫助和動力下才有可能實現。很高興看到我們可以逐步發布這些變更,並為需要程序沙箱化的新 Electron 版本做好準備。我們能夠極大地改善我們的程序架構,並更緊密地與 Web 模型保持一致,為未來創建穩固的基礎。

使用的術語

Electron 是使 VS Code 桌面版能夠在我們所有支援的平台(Windows、macOS 和 Linux)上運行的主要框架。它結合了 Chromium 與瀏覽器 API、V8 JavaScript 引擎和 Node.js API,以及平台整合 API,以建構跨平台桌面應用程式。

在這篇部落格文章中,我們將 Electron 程序沙箱化 簡稱為「沙箱」。

了解 Chromium 以及 Electron 提供的程序模型非常重要。在這篇部落格文章中,我們經常提到以下程序

  • 主程序 - 應用程式主進入點。
  • 渲染器程序 - 使用者可以與之互動的視窗。

雖然始終只有一個主程序,但每個開啟的視窗都會建立渲染器程序。您可以在 Electron 程序模型 文件和這篇 Chrome Developers 部落格文章 中了解更多關於程序模型的資訊。

「共享程序」並非 Electron 特有,而是 VS Code 的實作細節。它是一個啟用了 Node.js 的隱藏 Electron 視窗,所有其他視窗都可以與之通訊以執行複雜的任務,例如擴充功能安裝。

「擴充功能主機」是一個程序,它運行與渲染器程序隔離的所有已安裝擴充功能。每個開啟的視窗都有一個擴充功能主機。

VS Code「工作台」視窗是使用者與之互動以編輯檔案、搜尋或偵錯的主要視窗。在這篇部落格文章中,我們簡稱為「工作台」。其他視窗是程序瀏覽器和問題回報器,可以從說明選單存取。

我們使用術語「IPC」來表示跨程序通訊。IPC 是一種程序與另一個程序通訊的方式。

我們發布了 VS Code 的每夜版本,稱為「Insiders」,以在一小部分使用者中測試最新的變更。VS Code 團隊中的每個人都使用 Insiders 版本,我們希望您也嘗試一下並報告任何問題

Happy Coding!

Benjamin Pasero,@BenjaminPasero