將 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.js 模組(因此無法打包為 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 年初進行沙盒化時,我們已經發布了一個能夠在 Web 瀏覽器中執行的 VS Code 版本。您可以在瀏覽器中執行 vscode.dev,親身體驗 Visual Studio Code for the Web。在建立 VS Code Web 版本時,我們已經學會了如何從工作臺(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 的 UI 部分在本地執行,而實際的檔案操作在遠端機器上執行。此模型也適用於沙盒化工作臺,其中特權操作必須在不同的程序中執行。在這兩種情況下,渲染器程序都透過 IPC 與特權主機通訊以執行操作。
啟用渲染器通訊通道
當渲染器程序無法使用 Node.js 時,工作必須委託給另一個可使用 Node.js 的程序。在 Web 環境中,一種解決方案是依賴 HTTP 方法,由伺服器接受請求。然而,這對於桌面應用程式來說感覺不是最佳解決方案,因為在埠上執行本地伺服器可能會因安全原因被防火牆阻止。
Electron 提供了將預載入指令碼注入渲染器程序的功能,這些指令碼在主指令碼執行之前執行。這些指令碼可以訪問 Electron 自己的 IPC 機制。預載入指令碼可以透過 context bridge API 豐富渲染器主指令碼可用的 API。雖然預載入指令碼可以直接使用 Electron 的 IPC,但主指令碼不能。因此,我們透過 context bridge 向主指令碼公開某些方法。在我們開頭使用的示例中,下面是如何從預載入指令碼向主指令碼公開更新設定的方法:

預載入指令碼是我們分離特權程式碼和非特權程式碼的基本構建塊。例如,寫入磁碟檔案意味著包含新內容的 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 並可以使用它直接傳送訊息。

更改渲染器的來源
在 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 the Web 的投資,我們已經有了靜態分析工具,可以阻止 Node.js 程式碼被髮布到 Web 版本。此工具定義了一組具有執行時要求的目標環境。我們的工具可以檢測並報告在不允許 Node.js 的目標環境中對 Node.js 全域性物件(例如 Buffer)、Node.js API 或 Node.js 模組的使用。為了進行沙盒工作,我們添加了一個新的目標環境 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 程序都已移到共享程序的子程序或主程序的 utility process。訊息埠用於高效的直接程序間通訊,而不會給主程序帶來負擔。

調整 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 程序沙盒簡稱為“沙盒”。
瞭解 Chromium 以及 Electron 提供的程序模型非常重要。在這篇博文中,我們經常提到以下程序:
- main process - 應用程式主入口點。
- renderer process - 使用者可以與之互動的視窗。
雖然主程序始終只有一個,但每開啟一個視窗都會建立一個渲染器程序。您可以在 Electron Process Model 文件和這篇 Chrome Developers 博文中瞭解有關程序模型的更多資訊。
“shared process”(共享程序)不是 Electron 特有的,而是 VS Code 的實現細節。它是一個啟用了 Node.js 的隱藏 Electron 視窗,所有其他視窗都可以與之通訊以執行復雜任務,例如擴充套件安裝。
“extension host”(擴充套件主機)是一個執行所有已安裝擴充套件的程序,它與渲染器程序隔離。每個開啟的視窗有一個擴充套件主機。
VS Code“workbench”(工作臺)視窗是使用者與之互動以編輯檔案、搜尋或除錯的主視窗。在這篇博文中,我們將其簡稱為“工作臺”。其他視窗是程序資源管理器和問題報告器,可以從“幫助”選單訪問。
我們使用術語“IPC”來指代程序間通訊。IPC 是一種程序與另一個程序通訊的方式。
我們釋出了一個名為“Insiders”的 VS Code 每晚版本,用於在一部分使用者中測試最新更改。VS Code 團隊中的每個人都使用 Insiders 版本,我們希望您也嘗試一下並報告任何問題。
編碼愉快!
Benjamin Pasero,@BenjaminPasero