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 プロセスは、レンダラープロセスと呼ばれます。レンダラープロセスでサンドボックスモードを有効にすると、セキュリティを向上させ、よりウェブモデルに合わせるためにその機能が制限されます。HTML と JavaScript は引き続き許可されますが、Node.js の使用は許可されません。レンダラープロセス内のコンポーネントでシステムリソースへのアクセスが必要な場合は、サンドボックス化されていない別のプロセスに委任する必要があります。
以下のコードは Node.js に依存せず、設定を更新する機能を提供する vscode
グローバル変数を使用しています。このメソッドの実装には、Node.js にアクセスできる別のプロセスにメッセージを送信することが含まれます。そのため、もはや同期的に実行されるのではなく、非同期的に実行されます。
レンダラープロセスに vscode
グローバル変数が導入された経緯と、その実装方法については、以下のタイムラインセクションで詳しく説明されています。
レンダラープロセスからの Node.js のブロックは、Electron の推奨するセキュリティ推奨事項です。過去に、攻撃者がレンダラープロセスから任意の Node.js コードを実行できたセキュリティ上の問題がありました。サンドボックス化されたレンダラープロセスは、これらの攻撃のリスクを大幅に軽減します。
どのようにして実現したのか?
レンダラープロセスからすべての Node.js 依存関係を削除するという大きな変更には、回帰とバグのリスクが伴います。以前は1つのプロセスで実行されていたコードは、複数のプロセスに分割して実行する必要があります。ネイティブでありウェブパッケージ化できない Node モジュールも移動させる必要があります。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
型への依存関係は、ブラウザ環境では Uint8Array
にフォールバックするVSBuffer 同等物に置き換えられました。また、一部の Node.js モジュール (oniguruma、iconv-lite) をウェブ環境で実行できるようにパッケージ化することもできました。
しかし、VS Code for the Web が実現するよりも早く、私たちはリモート開発のサポートを有効にしていました。これにより、SSH 接続などを介してリモートホストでソースコードを編集できるようになり(後にはGitHub Codespaces の基盤にもなりました)、実際のファイル操作はリモートマシンで実行され、VS Code の UI 関連の部分はローカルで実行されるソリューションを実装する必要がありました。このモデルは、特権操作が別のプロセスで実行されなければならないサンドボックス化されたワークベンチにも当てはまります。どちらの場合も、レンダラープロセスは IPC を介して特権ホストと通信し、操作を実行します。
レンダラーからの通信チャネルを有効にする
レンダラープロセスが Node.js を使用できない場合、作業は Node.js が利用可能な別のプロセスに委任されなければなりません。ウェブの文脈での一つの解決策は、サーバーがリクエストを受け入れる HTTP メソッドに依存することでしょう。しかし、これはデスクトップアプリケーションにとって最適な解決策とは感じられませんでした。デスクトップアプリケーションでは、セキュリティ上の理由から、ポートでローカルサーバーを実行することがファイアウォールによってブロックされる可能性があるためです。
Electron は、メインスクリプトが実行される前に実行されるプリロードスクリプトをレンダラープロセスに注入する機能を提供します。これらのスクリプトは、Electron 独自のIPC メカニズムにアクセスできます。プリロードスクリプトは、コンテキストブリッジ API を介して、レンダラーのメインスクリプトで利用可能な API を拡張できます。プリロードスクリプトは Electron の IPC を直接使用できますが、メインスクリプトは使用できません。そのため、私たちは特定のメソッドをコンテキストブリッジを介してメインスクリプトに公開しています。冒頭で使用した例では、設定を更新するメソッドをプリロードスクリプトからメインスクリプトに公開する方法を以下に示します。
プリロードスクリプトは、特権コードと非特権コードを分割するための基本的な構成要素です。たとえば、ディスク上のファイルに書き込む場合、新しいコンテンツを含む IPC メッセージがメインスクリプトからプリロードスクリプトへ、そしてそこから Node.js にアクセスできるメインプロセスへと移動します。
メッセージポートを介した高速なプロセス間通信
プリロードスクリプトの導入により、レンダラープロセスが Electron のメインプロセスと通信して作業をスケジュールする方法ができました。しかし、Electron アプリケーションでは、メインプロセスに過度の負荷をかけないことが重要です。なぜなら、メインプロセスはキーボードやマウスからのユーザー入力を処理する役割も担っているからです。メインプロセスがビジーになると、ユーザーインターフェースが応答しなくなる可能性があります。
これは以前にも経験した問題でした。サンドボックス化に取り組む前から、私たちはパフォーマンス集約型のコードをバックグラウンドプロセス、すなわち VS Code の共有プロセスにオフロードすることに関心がありました。このプロセスは、すべてのワークベンチウィンドウとメインプロセスが通信できる非表示のウィンドウです。たとえば、拡張機能をインストールする場合、操作全体を実行するために共有プロセスにリクエストが送信されます。
しかし、共有プロセスへの通信は Node.js ソケットを介して実装されていました。これは、メインプロセスが通信にまったく関与しないため、オーバーヘッドがゼロであるという利点がありました。欠点は、Node.js API を使用できないため、サンドボックス化されたレンダラーでは Node.js ソケット通信が不可能であることです。
メッセージポートは、プロセス間に IPC チャネルを確立することで、2つのプロセスを相互に接続する強力な方法を提供します。完全にサンドボックス化されたレンダラープロセスでもメッセージポートを使用できます。なぜなら、これらはブラウザでウェブ 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 プロトコルと同じように動作するように構成できます。私たちはこのアプローチを使用して、コンテンツを提供するローカルウェブサーバーを実行する必要を避けました。
すべてのレンダラープロセスにカスタム vscode-file
プロトコルを導入することで、ファイルプロトコルのすべての使用を廃止することができました。これは HTTPS と同じように動作するように構成されており、VS Code for the Web が実際に動作する方法に近づいたことを意味します。
コードローダーの適応
歴史的に、私たちの TypeScript コードはすべて AMD モジュールにコンパイルされ、長年にわたって保守してきたカスタムローダーでロードされています。私たちは AMD から離れてESM を採用する予定ですが、その作業はまだ初期段階にあります。
私たちのコードローダーは、いくつかの明確に定義された変数をプローブして実際の実行環境を特定することで、Node.js 環境とウェブ環境の両方をサポートしています。サンドボックス化されたレンダラーは本質的にウェブ環境のようなものであるため、ローダーがサンドボックスをサポートするために必要な変更はごくわずかでした。
これらの変更が加えられると、サンドボックスモードが有効になった VS Code の初期バージョンを実行できるようになりました。しかし、レンダラープロセスを Node.js の依存関係からまだ解放していなかったため、エラーがコンソールに出力されるとともに、空白のページが表示されるだけでした。
採用を支援するツール
サンドボックスを有効にして VS Code を実行する方法ができた今、Node.js に依存するソースコードから「サンドボックス対応」のコードへの移行を容易にするためのツールに投資したいと考えました。VS Code for the Web への投資を考えると、私たちはすでに、Node.js コードがウェブバージョンにリリースされるのをブロックする静的解析ツールを用意していました。このツールは、ランタイム要件を持つ一連のターゲット環境を定義していました。私たちのツールは、Node.js グローバルオブジェクト (Buffer
など)、Node.js API、または Node モジュールの使用を、それらを許可しないターゲット環境で検出し、報告できます。サンドボックス化の作業のために、Node.js の使用を一切許可しない新しいターゲット環境であるelectron-sandboxを追加しました。この環境にコードを移動させることで、コードを段階的にサンドボックス対応にすることができました。
下のスクリーンショットでは、エディタに警告マーカーが表示され、browser ターゲット環境のファイルが Node.js の API に依存していることを示しています。この警告により、ビルドが失敗し、誤ってこのコードがリリースされるのを防ぎます。
私たちのプロセスエクスプローラーとイシューレポーターユーティリティは、electron-sandbox ターゲット要件に最初に適合したものの1つでした。ワークベンチウィンドウが採用を完了するずっと前に、これらのウィンドウを完全にサンドボックス化して実行することができました。
レンダラーからプロセスを移動する
これまでのトピックで詳細に説明したように、Node.js の機能の一部を別のプロセスに移動し、IPC を使用して作業をスケジュールし、結果を受け取ることは簡単です。
しかし、ワークベンチ内で Node.js に依存するコンポーネントの中には、より複雑なものがあります。特に子プロセスをフォークするものです。例えば、
- 拡張ホスト
- 統合ターミナル
- ファイル監視
- 全文検索
- タスク実行
- デバッグ
VS Code がリモートシナリオで実行できることを考えると、検索、デバッグ、タスク実行といった一部のタスクをリモートで実行するためのメカニズムはすでに備わっていました。これらのコンポーネントは、コードがある場所に自然にローカルで実行される拡張ホストプロセス内で動作できます。そのため、VS Code がリモートに接続されていないローカルで実行されている場合でも、これらの子プロセスの所有権をレンダラープロセスから拡張ホストに移行することができました。
拡張ホストについては、より野心的な計画がありました。これは Electron に新しい「ユーティリティプロセス」API を追加する必要があったため、後でその変更を独自のセクションで説明します。
統合ターミナルとファイル監視は、共有プロセスの子プロセスになりました。ファイル監視または統合ターミナルを必要とするウィンドウは、メッセージポートを介して共有プロセスと通信し、これらのサービスを取得します。
下の図は、レンダラープロセスでサンドボックスを有効にした後の2022年後半のプロセスアーキテクチャを示しています。すべての Node.js プロセスは、共有プロセスの子プロセスか、メインプロセスからのユーティリティプロセスに移行しました。メッセージポートは、メインプロセスに負担をかけることなく、効率的な直接的なプロセス間通信に使用されます。
Chromium のコードキャッシュの調整
サンドボックスを有効にしてもパフォーマンスの回帰が発生しないことも確認したかったのです。起動からエディタに点滅するカーソルが表示されるまでの時間を測定したところ、メインのワークベンチスクリプト(約11.5 MBのミニファイされたコード)を読み込み、解析し、実行するために V8 JavaScript エンジンでかなりの時間が費やされていることがわかりました。更新がインストールされない限り、同じスクリプトが起動ごとに読み込まれます。この動作を考慮し、V8 はコードキャッシュを使用して、次回より高速に読み込むことができる最適化されたバージョンのスクリプトをディスクに保存できます。
Chromium 自体も、ウェブページのロード時間を短縮するためにコードキャッシュを使用しています。これは私たちのソリューションと同じ V8 エンジンの最適化をトリガーしますが、Chromium の実装は特定の期間に頻繁にアクセスされるウェブページに対してのみこれを行います。私たちのアプリケーションはデスクトップアプリケーションでありウェブページではないため、常にコードキャッシュを使用するソリューションを望んでいました。
起動時にコードキャッシュを有効にしましたが、これは起動時間の改善に迅速に最も良いソリューションとなりました。残念ながら、私たちのソリューションはNode.js に依存しており、サンドボックス化されたレンダラープロセスでは適用できませんでした。
Electron でコードキャッシュオプションを公開することで、bypassHeatCheck オプションを使用する際に Chromium でコードキャッシュを強制的にトリガーできます。さらに、ユーザーが新しいバージョンの VS Code を実行していることを検出したときに、以前に生成されたコードキャッシュを破棄することで、保護層を追加しました。
新しい Electron API: UtilityProcess
最終的かつおそらく最も複雑なタスクは、拡張ホストをどこに移動するかという解決策を見つけることでした。共有プロセスと同様に、通信は Node.js ソケットを介して実装されていました。ウィンドウごとに1つの拡張ホストプロセスがあり、拡張機能は必要なだけ多くの子プロセスを生成できます。
ファイル監視や統合ターミナルと同様に、拡張ホストを共有プロセスに移動することを考えましたが、この機会を捉えて、ホストとして非表示ウィンドウを必要としない、より柔軟なものを構築すべきだと感じました。
そのため、私たちはサンドボックス化されたレンダラーで動作しつつ、現在の動作のほとんどを維持できる、堅牢でスケーラブルなソリューションを求めていました。
- 子プロセスの生成をサポートする独立したプロセス
- 完全な Node.js サポート
- サンドボックス化されたプロセスとの直接 IPC にメッセージポートを使用
当時、Electron はこれらの要件をサポートする API を提供できなかったため、私たちは新しいユーティリティプロセス API を Electron に貢献しました。この API により、拡張ホストをレンダラープロセスからメインプロセスから作成されるユーティリティプロセスへ移動させることができました。メッセージポートを使用することで、ユーザー入力を処理するメインプロセスなど、他のプロセスに影響を与えることなく、レンダラーと拡張ホスト間で直接通信できます。
Electron の webview 要素からの移行
サンドボックスを有効にするために必ずしも必要ではありませんでしたが、この機会に VS Code での Electron のwebview タグの使用を見直し、VS Code がウェブでどのように動作するかにより密接に合わせるためにiframe タグに置き換えました。どちらのタグも、ワークベンチをこのコードの実行の影響から隔離しながら、拡張機能からの信頼できないコードをワークベンチがホストできるという点で似ています。たとえば、Markdown ファイルのプレビューを開くと、その内容は組み込みの Markdown 拡張機能によって提供される、そのような要素でレンダリングされます。
ほとんどの場合、webview
タグを iframe
タグに置き換えるだけで済みました。しかし、iframe
にはコンテンツ内のテキスト検索を実行してハイライト表示する機能が欠けていました。この機能は、Markdown ドキュメントをプレビューする際に検索をサポートするために非常に重要でした。Chromium は内部的にこの機能を実装していましたが、ウェブ API としては公開されていませんでした。私たちは Electron で API を公開するために必要な変更を加え、webview
要素のすべての使用を廃止することができました。
レンダラープロセスの再利用を有効にする
サンドボックス化されたレンダラープロセスのパフォーマンス上の利点の一つは、Electron におけるそのライフサイクル動作です。従来、レンダラープロセスは別の URL へのナビゲーションが発生するたびに終了し、再起動していました。VS Code の場合、これはワークスペースを変更したり、ウィンドウをリロードしたりするたびにレンダラープロセスが再作成されることを意味し、一部の環境や設定では処理が遅くなる可能性がありました。
サンドボックス化されたレンダラープロセスは、URL をナビゲートしても存続します。別のワークスペースを開いたり、現在のワークスペースをリロードしたりするのがはるかに速くなります。しかし、これを機能させるには、レンダラープロセスで実行されるネイティブ Node.js モジュールをコンテキスト認識にする必要があります。サンドボックス化を有効にするためにすべてのネイティブモジュールをレンダラープロセスから移動させましたが、早期にレンダラープロセスの再利用をテストしたかったため、すべてのネイティブモジュールをコンテキスト認識にしました。
全体のまとめ
最後のステップは、ユーザー設定を介してサンドボックスモードを条件付きで有効にすることでした。すべてのユーザーにサンドボックスモードを有効にするのではなく、私たちのInsiders 版で検証される時間を設けたかったのです。window.experimental.useSandbox 設定により、Insiders ではサンドボックスがデフォルトで有効になっており、Stable 版でも有効にできます。
2023年初頭に、実験インフラストラクチャを使用して、サンドボックスの有効化を Stable 版に段階的に展開する予定です。これにより、問題をチェックしながら、増え続けるユーザーセットでサンドボックスモードをテストおよび検証できるようになります。
実験段階が終了すると、サンドボックスモードはすべてのユーザーに対してデフォルトで有効になり、非サンドボックスモードは削除されます。後のイテレーションでまだいくつかの作業が計画されており、例えば、共有プロセスは非表示のウィンドウであり、必要以上のリソースを使用するため、ユーティリティプロセスに変換したいと考えています。
これは、VS Code チーム全体の協力とモチベーションがあって初めて可能になった素晴らしい道のりでした。これらの変更を段階的にリリースでき、プロセスサンドボックス化を必要とする新しい Electron バージョンに対応する準備ができたことは素晴らしいことでした。私たちのプロセスアーキテクチャを大幅に改善し、ウェブモデルにさらに密接に合わせることで、将来のための堅牢な基盤を築くことができました。
使用される用語
Electron は、VS Code デスクトップ版をサポートされているすべてのプラットフォーム (Windows, macOS, Linux) で動作させる主要なフレームワークです。Chromium とブラウザ API、V8 JavaScript エンジン、そしてNode.js API、さらにプラットフォームの統合 API を組み合わせて、クロスプラットフォームのデスクトップアプリケーションを構築します。
このブログ記事では、Electron のプロセスサンドボックスを単に「サンドボックス」と呼びます。
Chromium、ひいては Electron が提供するプロセスモデルを理解することが重要です。このブログ記事では、以下のプロセスについて頻繁に言及します。
- メインプロセス - アプリケーションの主要なエントリーポイント。
- レンダラープロセス - ユーザーが操作できるウィンドウ。
メインプロセスは常に1つですが、レンダラープロセスは開かれたウィンドウごとに作成されます。プロセスモデルについては、Electron のプロセスモデルドキュメントとこのChrome Developers のブログ記事で詳しく学ぶことができます。
「共有プロセス」は Electron に固有のものではなく、VS Code の実装詳細です。これは Node.js が有効になっている非表示の Electron ウィンドウで、他のすべてのウィンドウが拡張機能のインストールなどの複雑なタスクを実行するために通信できます。
「拡張ホスト」は、すべてのインストールされた拡張機能をレンダラープロセスから隔離して実行するプロセスです。開かれたウィンドウごとに1つの拡張ホストがあります。
VS Code の「ワークベンチ」ウィンドウは、ユーザーがファイルの編集、検索、デバッグを行うために操作するメインウィンドウです。このブログ記事では、これを単に「ワークベンチ」と呼びます。他のウィンドウはプロセスエクスプローラーとイシューレポーターで、ヘルプメニューからアクセスできます。
「IPC」という用語は、プロセス間通信を指すために使用します。IPC は、あるプロセスが別のプロセスと通信する方法です。
私たちは、最新の変更を一部のユーザーでテストするために、「Insiders」と呼ばれる VS Code の nightly バージョンをリリースしています。VS Code チームの全員がInsiders 版を使用しており、ぜひ皆さんも試して問題があれば報告してくださることを願っています。
ハッピーコーディング!
Benjamin Pasero, @BenjaminPasero