將 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 socket 實作的,而渲染器程序是 Node.js API 的主要用戶端,例如讀取和寫入檔案。

VS Code process model before sandboxing in 2020

我們很快就決定,我們希望在不需發行個別的沙箱化 VS Code 應用程式的情況下,進行程序沙箱工作。我們希望逐步讓 VS Code 渲染器程序準備好沙箱化,然後在最後切換開關。在過去幾年中,我們發行了 VS Code 的每月穩定版本,其中包含有助於沙箱目標的變更,但並未完全啟用沙箱。想像一下,一架飛機在空中飛行時,正在進行根本性的重建。而在我們的情況下,使用者大多沒有注意到 VS Code 的變更。

我們的技術時程

接下來的章節將詳細介紹沙箱在過去幾年是如何整合在一起的。主要任務是從渲染器程序中移除所有 Node.js 相依性,但在此過程中出現了更多挑戰,例如找出有效率且適用於沙箱的 IPC 解決方案 (透過 MessagePort 協助),或為我們可以從渲染器程序分岔的各種 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 能夠將預先載入指令碼注入到渲染器程序中,這些指令碼會在主要指令碼執行之前執行。這些指令碼可以存取 Electron 自己的IPC 機制。預先載入指令碼可以透過內容橋接器 API,豐富渲染器主要指令碼可用的 API。雖然預先載入指令碼可以直接使用 Electron 的 IPC,但主要指令碼則無法。因此,我們透過內容橋接器向主要指令碼公開了某些方法。在我們一開始使用的範例中,以下是如何從預先載入指令碼將更新設定的方法公開到主要指令碼

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 socket 通訊在沙箱化的渲染器中是不可行的,因為您無法使用任何 Node.js API。

訊息埠提供了一種強大的方式,可透過在兩個程序之間建立 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

變更渲染器的來源

在 Web 瀏覽器中,您輸入 URL,然後載入並呈現內容。在 Electron 中,您不會輸入 URL,而是由應用程式為您決定要載入和呈現哪些內容。因此,當您開啟 VS Code 時,視窗會載入預先設定的 URL,以顯示工作台的內容。

對於 VS Code,此 URL 過去曾使用本機檔案通訊協定指向磁碟上的實際檔案以進行載入 (file://<path to file on disk>)。作為沙箱工作的一部分,我們重新檢視了此方法,因為它具有嚴重的安全性意涵。相較於 HTTPS 通訊協定,Chromium 對於本機檔案通訊協定做了某些安全性假設,這些假設較不嚴格。例如,不會對本機檔案通訊協定 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 全域物件 (例如 Buffer)、Node.js API 或節點模組的目標環境中使用它們的情況。對於沙箱工作,我們新增了一個新的目標環境 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 在本機執行且未附加遠端,我們也能夠將這些子程序的所有權從渲染器程序移至擴充功能主機。

對於擴充功能主機,我們有更遠大的計畫。我們稍後會在自己的章節中涵蓋這些變更,因為這需要將新的「公用程式程序」API 新增至 Electron。

整合式終端機和檔案監看已移至成為共用程序的子程序。任何需要檔案監看或整合式終端機的視窗都會透過訊息埠與共用程序通訊,以取得這些服務。

下圖顯示了我們在 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 在 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 中啟用,並且可以在穩定版中啟用。

我們計畫使用我們的實驗基礎架構,在 2023 年初逐步將沙箱啟用推廣到我們的穩定版。這將讓我們能夠在檢查問題時,測試和驗證越來越多使用者群的沙箱模式。

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

這是一趟令人驚嘆的旅程,只有在整個 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 的「workbench」視窗是使用者與之互動以編輯檔案、搜尋或偵錯的主要視窗。在這篇部落格文章中,我們將其簡稱為「workbench」。其他視窗為程序總管和問題回報器,可從說明選單存取。

我們使用術語「IPC」來指稱行間處理程序通訊 (inter-process communication)。IPC 是一種讓一個處理程序與另一個處理程序通訊的方式。

我們發布 VS Code 的每夜建置版本,稱為「Insiders」,以在部分使用者上測試最新的變更。VS Code 團隊的每個人都使用 Insiders 版本,我們希望您也能試用看看並回報任何問題

祝您編碼愉快!

Benjamin Pasero,@BenjaminPasero