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

# この記事に書くこと
筆者はSlackメッセージをデスクトップ上に表示するアプリ[Cheer](https://cheer.konkonta.com/) をElectronで開発しています。

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





## contextIsolationとは

公式の説明は以下です。

[コンテキストの分離|Electron](https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation)
[セキュリティ|Electron](https://www.electronjs.org/ja/docs/latest/tutorial/security#3-enable-context-isolation)

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

### `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](https://www.electronjs.org/ja/docs/latest/tutorial/tutorial-preload)

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

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

```typescript
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のメソッドを露出させるのではなく、以下のようにアプリの機能のみを露出させるべきと公式ドキュメントに書かれています。

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

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

### どのようなAPIをexposeすれば良いのか
contextIsolationを有効にした環境下では、レンダラープロセスでは実行できない処理をメインプロセスに実行させて実行結果をレンダラープロセスに通知してもらう必要があります。
レンダラープロセスでは実行できない処理の具体的な例が以下です。
- NodeのAPIに依存している処理
- NodeのAPIに依存しているライブラリを呼び出す処理
- 認証情報などの秘匿情報を扱う処理

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

#### NodeのAPIに依存している処理
具体的な例としては、[fs](https://nodejs.org/api/fs.html)や[path](https://nodejs.org/api/path.html)などがよく使うAPIだと思います。process.envなどの参照する場合も同様です。
先に説明した通り、`contextIsolation`を有効にした状態ではレンダラープロセスからNode.jsを参照することはできません。

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


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

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



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

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


### 具体的なコード
pleloadは以下のようなAPIを露出させることになるでしょう。
```typescript
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`を例にメインプロセスのコードを記載します。
```typescript
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公式 破壊的変更](https://www.electronjs.org/ja/docs/latest/breaking-changes#%E7%9C%81%E7%95%A5%E5%80%A4%E5%A4%89%E6%9B%B4-contextisolation-%E3%81%AE%E7%9C%81%E7%95%A5%E5%80%A4%E3%82%92-true-%E3%81%AB)

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






## 終わりに　〜この記事を書いた背景〜
プライベートで開発したElectron製デスクトップアプリケーション[Cheer](https://cheer.konkonta.com/)を公開しています。
それなりに利用してくれている人がいるので、自身の勉強も兼ねてメンテナンスを続けています。

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