💨

[Electron] contextBridgeでセキュアなIPC通信を実現する(TypeScript)

2021/11/29に公開

はじめに

Electron v12よりcontextIsolationオプションがデフォルトで true に変更されています。
これにより今まで通りレンダラープロセスからNode.jsの機能を呼び出そうとするとエラーが発生して実行できません。(実際には呼び出し可能ですが、セキュリティ上の観点から推奨されません)

import { ipcRenderer, IpcRendererEvent } = from 'electron';
// エラーになる
ipcRenderer.on(
  'onReceiveMessage',
  (event: IpcRendererEvent, message: string) => {
    console.log(message);
  },
);

レンダープロセスが Node 結合されたままだと悪意のあるスクリプトが混入した際に OS のネイティブコードを実行される恐れがあり非常に危険です。

しかし IPC 通信などでは引き続きレンダラープロセスでNode.jsの機能を使用する必要があります。その場合、予め Preload スクリプトとしてコードを切り出しておき、contextIsolationでレンダラープロセスにブリッジする方法が推奨されており、今回はこの方法を使用して進めて行きます。言語はTypeScript、フロントにはReactを使用します。

preload を用意する

preload.ts
// この中に処理を予め記述しておく
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';

contextBridge.exposeInMainWorld('electron', {
  send: (channel: string, ...args: any[]) => {
    ipcRenderer.send(channel, ...args);
  },
  on: (
    channel: string,
    listener: (event: IpcRendererEvent, ...args: any[]) => void,
  ) => {
    ipcRenderer.on(channel, listener);
  },
});

Preload スクリプトを使用して IPC 通信をこのように書くことが出来ます。
しかし、これでは任意の IPC メッセージを送信できてしまいセキュリティ的に問題があるので、関数で包んで部分的に公開するか、チャンネル名をフィルタリングする事が望ましいです。

constants.ts
// チャンネルを定数で管理する
export const IPCKeys = {
  RECEIVE_MESSAGE: 'receiveMessage',
  SEND_MESSSAGE: 'sendMessage',
} as const;
preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import { IPCKeys } from './constants';

contextBridge.exposeInMainWorld('myAPI', {
  // 関数で包んで部分的に公開する
  // renderer -> main
  sendMessage: (message: string) => {
    ipcRenderer.send(IPCKeys.SEND_MESSSAGE, message);
  },
  // main -> renderer
  onReceiveMessage: (listener: (message: string) => void) => {
    ipcRenderer.on(
      IPCKeys.RECEIVE_MESSAGE,
      (event: IpcRendererEvent, message: string) => listener(message),
    );
    return () => {
      ipcRenderer.removeAllListeners(IPCKeys.RECEIVE_MESSAGE);
    };
  },
});

型定義ファイルを用意する

今回はTypeScriptを使用している為、型定義ファイルを@typesに配置して window オブジェクトを拡張します。

src/@types/global.d.ts
declare global {
  interface Window {
    myAPI: IMyAPI;
  }
}
export interface IMyAPI {
  sendMessage: (message: string) => void;
  onReceiveMessage: (listener: (message: string) => void) => () => void;
}

メインプロセスで Preload スクリプトを設定する

メインプロセスで Preload スクリプトを読み込ませます。

main.ts
import {
  BrowserWindow,
} from 'electron';

const mainWindow = new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    // Preloadスクリプトを読み込む
    preload: path.join(__dirname, 'preload.js'),
  },
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));

レンダラープロセス (React)

ここまで進めれば window オブジェクトに先ほど作成したmyAPIが追加されているはずです。
useEffect内でイベントリスナーを追加して、返り値で受け取ったクロージャでクリーンアップ時にイベントリスナーを削除する事で重複登録を防ぎます。

App.tsx
import React, { useEffect, useState  } from 'react';

const { myAPI } = window;

export const Component: React.FC = () => {
  const [state, setState] = useState('');

  useEffect(() => {
    // イベントリスナーを追加
    const removeListener = myAPI.onReceiveMessage(
      (message: string) => {
        setState(message);
      },
    );
    // コンポーネントのクリーンアップ処理でイベントリスナーを削除する
    return () => {
      removeListener();
    };
  }, []);

  return (
    <>
      <button onClick={() => myAPI.sendMessage('hoge')}>
        Send Message
      </button>
    </>
  );
};

まとめ

以前に比べるとひと手間必要ですが、関数でラップしているので型安全に書けるメリットもあります。どうせならメインプロセス側も手を加えて、もう少し本格的な API ぽく使えるようにしてみたい気もしますが、それはまた別の機会に試したいと思います。

Discussion