[Electron] contextBridgeでセキュアなIPC通信を実現する(TypeScript)
はじめに
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 を用意する
// この中に処理を予め記述しておく
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 メッセージを送信できてしまいセキュリティ的に問題があるので、関数で包んで部分的に公開するか、チャンネル名をフィルタリングする事が望ましいです。
// チャンネルを定数で管理する
export const IPCKeys = {
RECEIVE_MESSAGE: 'receiveMessage',
SEND_MESSSAGE: 'sendMessage',
} as const;
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 オブジェクトを拡張します。
declare global {
interface Window {
myAPI: IMyAPI;
}
}
export interface IMyAPI {
sendMessage: (message: string) => void;
onReceiveMessage: (listener: (message: string) => void) => () => void;
}
メインプロセスで Preload スクリプトを設定する
メインプロセスで Preload スクリプトを読み込ませます。
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
内でイベントリスナーを追加して、返り値で受け取ったクロージャでクリーンアップ時にイベントリスナーを削除する事で重複登録を防ぎます。
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