🤖

Electronアプリのセキュリティを向上させるcontextIsolationについて

2023/09/10に公開

この記事に書くこと

筆者はSlackメッセージをデスクトップ上に表示するアプリCheer をElectronで開発しています。

今回は、Electronのセキュリティ向上のためのコンセプトの一つcontextIsolationについて解説します。

contextIsolationとは

公式の説明は以下です。

コンテキストの分離|Electron
セキュリティ|Electron

簡単に書くと、セキュリティのためにレンダラープロセスを隔離しようという話です。

contextIsolationを無効にした場合

レンダラープロセスからNode.jsにアクセスする手段がいくつか解放されます。
最もわかりやすい例は、nodeIntegration: trueを設定することでレンダラープロセスにNode.jsを露出させることができるようになります。
レンダラープロセスで行える処理の幅が増えるので実装難易度は下がると思いますが、XSS等の攻撃を受けた場合に露出したNode.jsのAPIを介してファイルシステム等にまでアクセスが可能になってしまいます。
リモートコードの読み込みや、他のWebサイトのようなインターネット上のリソースを読み込むようなアプリケーションにおいてはリスクのある選択肢となります。

contextIsolationを有効にした場合

レンダラープロセスからNode.jsにアクセスすることはできなくなります。nodeIntegration: trueを設定したとしても反映されません。
Node.jsに依存した処理は必ずメインプロセスで行い、そのインターフェースのみをレンダラープロセスに露出させるという方式で実装する必要があります。

具体的な実装

contextIsolationに対応するための以下のような箇所の実装は工夫が必要になります。

  • Node.jsのAPIを使用したい
  • Node.jsに依存したライブラリを使用したい

electronはNode.jsに依存しているため、レンダラープロセスでimportするとエラーになります。
ipcRendererのようなレンダラープロセス用のAPIを利用する場合は、preload scriptを使用してメインプロセスのAPIを公開する必要があります。

preload scriptでレンダラープロセスにAPIを公開する

プリロードスクリプトの利用|Electron

レンダラープロセスでelectronをimportすることができないとはいえ、ipcRendererのようなレンダラープロセスで使うことが想定されたモジュールはレンダラープロセスから呼び出せる必要があります。
また、process.envのようなNode.jsに依存した情報を参照したいこともあるでしょう。

そこで、以下のようなpreload scriptを作成すると、ipcRendererとNode.jsのprocessをレンダラープロセスに露出させることができます。

const { contextBridge, ipcRenderer } = require('electron');

const electronHandler = {
  ipcRenderer: {
    send(channel: string, ...args: any[]) {
      ipcRenderer.send(channel, args);
    },
    on(channel: string, func: (...args: any) => void) {
      ipcRenderer.on(channel, func);
      return () => {
        ipcRenderer.removeListener(channel, func);
      };
    },
    invoke(channel: string, ...args: any[]) {
      return ipcRenderer.invoke(channel, ...args);
    },
  },
};

export type ElectronHandler = typeof electronHandler;

contextBridge.exposeInMainWorld('electron', electronHandler);
contextBridge.exposeInMainWorld('node', {
  process,
});

これで、レンダラープロセスからはwindowオブジェクトを経由してipcRendererを使用することができるようになります。しかし、このpreload scriptは理想的な書き方ではありません。
より堅牢にするためには、ipcRendererのメソッドを露出させるのではなく、以下のようにアプリの機能のみを露出させるべきと公式ドキュメントに書かれています。

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('versions', {
  getUser: () => ipcRenderer.invoke('getUser')
})

どのようなAPIをexposeすれば良いのか

contextIsolationを有効にした環境下では、レンダラープロセスでは実行できない処理をメインプロセスに実行させて実行結果をレンダラープロセスに通知してもらう必要があります。
レンダラープロセスでは実行できない処理の具体的な例が以下です。

  • NodeのAPIに依存している処理
  • NodeのAPIに依存しているライブラリを呼び出す処理
  • 認証情報などの秘匿情報を扱う処理

それぞれについて、さらに具体的に詳細を解説します。

NodeのAPIに依存している処理

具体的な例としては、fspathなどがよく使うAPIだと思います。process.envなどの参照する場合も同様です。
先に説明した通り、contextIsolationを有効にした状態ではレンダラープロセスからNode.jsを参照することはできません。

preloadスクリプトで露出させたAPIを経由してipcRendererを利用し、メインプロセスでfs等を実行させて結果を返してもらう必要があります。

NodeのAPIに依存しているライブラリを呼び出す処理

当然、私たちが書くコードだけでなくレンダラープロセスで利用するライブラリがNode.jsのAPIに依存しているかどうかも注意する必要があります。

Electronでよく使うNode.jsに依存したライブラリといえばElectronそのものや、electron-storeなどがあります。
レンダラープロセスからstoreを参照するのではなく、ipcRendererで通知してメインプロセスでelectron-storeをimportし、storeに読み書きを行った結果を返してもらう必要なあります。

認証情報などの秘匿情報を扱う処理

APIキーや暗号化に使ったソルトなどの秘匿情報はレンダラープロセスに露出させるべきではありません。
(Webアプリケーションでクライアントに露出させてはいけないのと同じです。)

キーが必要なWeb APIを叩く処理はメインプロセスに移譲する必要があります。

具体的なコード

pleloadは以下のようなAPIを露出させることになるでしょう。

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('myApi', {
  // メインプロセスはこの通知を受け取るとfsでconfigファイルをロードし、結果を返す
  getConfig: () => ipcRenderer.invoke('getConfig')

  // メインプロセスはこの通知を受け取るとelectron-storeで書き込みを行う
  writeUserInfo: (body) => ipcRenderer.call('writeUserInfo', body)

  // メインプロセスはこの通知を受け取るとWeb APIから情報を取得して結果を返す
  fetchUserInfo: () => ipcRenderer.invoke('fetchUserInfo')
})

メインプロセス側では以下のようなハンドラーを設置することでレンダラープロセスと情報をやり取りすることができます。
ここでは上記の内のfetchUserInfoを例にメインプロセスのコードを記載します。

import { ipcMain } from 'electron';
import Store from 'electron-store';

ipcMain.on('writeUserInfo', (e, body) => {
  // レンダラープロセスから値を受け取り、electoron-storeで保存する
  const store = new Store<any>();
  store.set("userInfo", body);
});

contextIsolationの歴史

Electron Ver 11まで

XSSなどの攻撃に対して脆弱になるため、Ver11以前でもtrueに設定することが推奨されていましたがデフォルトでは無効になっていました。

// (ver11までは省略値)contextIsolation: false 
nodeIntegration: true

上記を設定するとレンダラープロセスからもNode.jsのAPIやelectronのAPIが実行可能になるため、開発難易度が下がります。

余談ですが、私が個人で開発したアプリの初期リリース時点ではVer 8でありcontextIsolation: falseがデフォルト値だったため、脆弱性を許容して開発の容易性を取っていました。

Ver 12で規定値の変更

ElectronのVer12において、contextIsolationの省略値がtrueになる破壊的変更が行われていました。
参考: Electron公式 破壊的変更

ElectronのVer 12をまたぐバージョンアップ作業を行う方は注意してください。
contextIsolationの設定値を規定値のままでレンダラープロセスでNodeのAPIを使用していた場合、アプリが動作しなくなります。
`module not found'をはじめとしたエラーが発生しますが、エラーメッセージから原因が分かりにくいです。
(私は最初アプリか動作しなくなった原因がわからず、調査に多くの時間を費やしました)

終わりに 〜この記事を書いた背景〜

プライベートで開発したElectron製デスクトップアプリケーションCheerを公開しています。
それなりに利用してくれている人がいるので、自身の勉強も兼ねてメンテナンスを続けています。

Ver8からVe22への大幅なElectronアップデートを行った際にアプリが動作しなくなりました。
いくつかのマイグレーション作業を行いましたが、その中で最も労力を必要としたのがcontextIsolation対応です。
私が開発を行った時には日本語のわかりやすい記事が少なかったため、今回この記事を書きました。
少しでも学習者の助けになれば幸いです。

Discussion