ElectronでcontextBridgeによる安全なIPC通信


はじめに

Electronの情報って、検索すると沢山出てくるのに、ところどころみな違っていて見極めが難しいですよね。まだまだ私自身よくわかっていないですが、調べた情報を共有します。

現時点での結論として、セキュアなIPC通信にはcontextBridgeを使おう、ということらしいです。

とはいえ、Electronの状況はversionによってかなり変わるようなので、以下の際内容には注意してください。こちらで検証した時点でのElectronのversionは7.1.9です。

Electronにおけるセキュアな設計とは

前提として、Electronでは、メインプロセスと、webページ画面として動くレンダラープロセスが立ち上がります。最初にelectronコマンドの引数として指定したjsファイル(今回はmain.jsとします)がmainプロセス上で実行され、

$ electron ./main.js

その中でBrowserWindow.loadURL()関数などで読み込まれたhtmlがレンダラープロセス上で起動します(今回はindex.htmlとします)。また、index.html上で読み込まれたjsファイルもレンダラープロセス上で実行されます。

たたき台として、以下のようなコードが最小コードとしましょう。

/* main.js, case 0 (initial) **************************/
const {electron,BrowserWindow,app} = require('electron');
let mainWindow = null;
const CreateWindow = () => {
    mainWindow = new BrowserWindow({width: 800, height: 600});
    mainWindow.loadURL('file://' + __dirname + '/index.html');
    mainWindow.webContents.openDevTools(); 
    mainWindow.on('closed', function() {
        mainWindow = null;
    });
}
app.on('ready', CreateWindow);
<!--index.html, case 0 (initial) -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Test</title>
  </head>
  <body>
    <button id="button1">test1</button>
  </body>
  <script type = "text/javascript">
      //適当なプログラム
      const electron = require('electron');//これがエラーになる
      const {ipcRenderer} = require('electron');//これもエラー
 </script>
</html>

ここで、昔のversionのElectronではレンダラープロセス上でもファイル読み書きなどのnodeの便利なメソッドが使えたわけですが、最近はdefaultでは使えなくなっているそうです。ですので、上記の様にレンダラープロセス上の「適当なプログラム」の部分でrequire('electron')と書いて実行しようとすると、"Uncaught ReferenceError: require is not defined at index.html"のようなエラーメッセージが出ます。

じゃあ、ファイル読み書きなどのnodeの機能はメインプロセス上だけでやろう、という方針を取るにしても、レンダラープロセスからの信号や情報をメインプロセスへ伝える手段がいるわけです。プロセス間の通信はIPC通信としてElectronのAPIが用意されているものの、最低限レンダラープロセス上での通信処理を司るipcRendererが欲しくなります(公式docs)。しかし、requireが使えないのでそれすら取得できません。

どうしましょう。

巷の情報

検索して出てくる情報は以下のようなものが多いです。

  1. nodeIntegration: trueにすればよい。

  2. セキュアにするにはnodeIntegration: falseのままにすべし。

  3. その代わりpreloadを使おう。

  4. preload内で準備したオブジェクトや関数をレンダラープロセスのjsで使うためには、(globalや)windowの変数に追加することでインスタンスを渡そう。

  5. あるversion以降、プロセス間でwindowが同一のオブジェクトではなくなった。よって受け渡しできない。同一オブジェクトにするにはcontextIsolation: falseとしよう。

  6. いやいや、セキュアにするにはcontextIsolation: trueのままにしよう。

  7. contextBridgeを使えば、nodeIntegration: false,contextIsolation: trueでもIPC通信できる 1 2

巡り巡って、どうやら、7番の方法で解決みたいですが、それ以前の手立ても含めて以下にまとめていきます。

方法1(情報1): nodeIntegration: true

nodeIntegrationというのは、メインプロセスでウィンドウを生成するとき位のオプションで指定します。先のmain.jsにおいて、BrowserWindowの生成部分のコードを以下の様に書き替えます。

/* main.js, case 1 */
// ~略~ //
const CreateWindow = () => {
  mainWindow = new BrowserWindow({width: 800, height: 600, 
                 webPreferences: { 
                   nodeIntegration: true,
                 } 
               });
// ~略~ //

これだけで、レンダラープロセスでrequire関数が使えるようになります。しかし、デバッグコンソールには"Electron Security Warning (Insecure Content-Security-Policy)"というwarningメッセージがでてきて、なにやら危ないようです。XSSの危険が大きいということで、あまりお勧めできないようです。

方法2(情報2-6):preloadを使う

では、nodeintegration: falseとしながら、レンダラープロセスでせめてIPC通信だけでもするにはどうするのか。そこで出てくるのがpreloadで追加jsを先行して読ませる方法です。読ませるjsをpreload.jsとします。このpreload.jsにおいてはnode.jsの機能、つまりrequire関数が使えるので、これをグローバルなオブジェクト変数として記録します。それをレンダラープロセスから使うということになります。コードで書くと、次のようになります。

/* main.js, case 2 */
//ipcMainの追加
const {electron,BrowserWindow,app,ipcMain} = require('electron');
let mainWindow = null;
const CreateWindow = () => {
    mainWindow = new BrowserWindow({width: 800, height: 600,
        webPreferences: { 
            nodeIntegration: false, //ここはfalseのまま
            contextIsolation: false,  //これをfalseに
            preload: __dirname + '/preload.js' //preloadするjs指定
        } });
    mainWindow.loadURL('file://' + __dirname + '/index.html');
    mainWindow.webContents.openDevTools(); 
    mainWindow.on('closed', function() {
        mainWindow = null;
    });
}
app.on('ready', CreateWindow);
//IPCメッセージの受信部(レンダラープロセスから送られる)//
ipcMain.on("msg_render_to_main", (event, arg) => {
    console.log(arg); //printing "good job"
});
/* preload.js, case 2*/
const {ipcRenderer} = require('electron');
window.MyIPCSend = (msg)=>{         
    ipcRenderer.send("msg_render_to_main", msg);
}
<!-- index.html, case 2 -->
<!DOCTYPE html>
<html>
~~略~~
<script type = "text/javascript">
  //適当なプログラム
  const button1 = document.getElementById("button1");
  button1.addEventListener("click", (e)=>{
      window.MyIPCSend("good job");});      
 </script>
</html>

まず、main.jsでは、BrowserWindowの生成のoptionにpreloadcontextIsolationの項目を追加しています。またIPCメッセージの受信部としてipcMain.onを設定しています。

preload.jsではrequireが利用できるので、グローバル変数としてwindow.MyIPCSend(msg)関数を追加し、その中でipcRendererを使ったメッセージ送信の機能を持たせます。ここからメインプロセスのipcMain.onへメッセージを送ります。

index.htmlではボタンを押したときにwindow.MyIPCSend(msg)関数を呼び出します。これはpreload.jsで定義したものですが、グローバルなwindowオブジェクトに保持されているので使えるようです。

このような形でIPCメッセージだけでもやり取りできれば、それで必要な情報を送り、node関連の機能を使った処理は全てメインプロセスへ押し付けてしまうこともできるでしょう。

ところがこの方法でも、contextIsolation: falseが必要です。あるversionからデフォルトではcontextIsolation: trueとなったようです。そしてセキュアにするには、ここもtrueがよいと。しかし、trueとすると、preload.jsから呼び出したwindowと、index.htmlで呼び出すwindowのインスタンスが別物になってしまいます。よって、window.MyIPCSend(msg)関数をindex.htmlから呼び出しても、定義されていない旨のエラーメッセージが出ます。

方法3(情報7):contextBridgeを利用する

さて、nodeIntegration: falseかつcontextIsolation: trueのままでIPC通信する手段として、contextBridgeというElectron APIがあるそうです1。これはElectronで公式に提案されたセキュアなプロセス間通信の実現のためのAPIだそうです(これを見つけた時は、嬉しくて叫んじゃいました)。

コードは次のようになります。

/* main.js, case 3 (final) */
// ~~略~~ ここまでcase2と同じ//
    mainWindow = new BrowserWindow({width: 800, height: 600,
        webPreferences: { 
            nodeIntegration: false, //ここはfalseのまま
            contextIsolation: true,  //trueのまま(case2と違う)
            preload: __dirname + '/preload.js' //preloadするjs指定
        } });
// ~~略~~ 以後もcase2と同じ//
/* preload.js, case 3 (final)*/
const { contextBridge, ipcRenderer} = require("electron");
contextBridge.exposeInMainWorld(
    "api", {
        send: (data) => {
            ipcRenderer.send("msg_render_to_main", data);
        }
    }
);
<!-- index.html, case 3 (final) -->
<!DOCTYPE html>
<html>
~~略~~
<script type = "text/javascript">
  //適当なプログラム
  const button1 = document.getElementById("button1");
  button1.addEventListener("click", (e)=>{
      window.api.send("god job");});      
 </script>
</html>

さて、main.jsは方法2と比べてcontextIsolation: trueに変えただけです。

大きく変わったのはpreload.jsです。electronからオブジェクトcontextBridgeを取り出し、exposeInMainWorld()によってグローバルな関数send()を登録しています。ここで登録した関数は、レンダラープロセスのindex.htmlの中からもwindow.api.send()として呼び出すことができます。

めでたし、めでたし。

注意点

contextBridgeはとっても良さそうなAPIですが、Electronのドキュメント3には次のように書かれています。

"The contextBridge API has been published to Electron's master branch, but has not yet been included in an Electron release."

一応、私の環境のversion7.1.9では使えていますが、いつから使えるようになったのかはちょっと不明なので、気を付けてください。

感想

HTML+Javascriptでブラウザ上だけでほぼ動くものを作ってしまえば、パッケージングはElectronですぐにできると思っていた時期が僕にもありました。。。

この記事がだれかの参考になれば幸いです。とはいえ、なにぶんJavascriptはライト勢なので、間違いもたくさんありそう。ご指摘いただければ大変嬉しいです。

追記 2020/2/13: レンダラープロセスでの受信編も投稿しました。

追記 2020/9/23

contextBridgeのより良い使い方として、もう少し注意すべき点があります。
上記のサンプルコードは、Electronのversion upの歴史に沿ってコードをどのように改変していくかを示すために残しておきますが、使用する前に下記の記事も是非読んでみてください。

Electronのセキュリティについて大きく誤認していたこと

Electron(v10.1.2現在)の IPC 通信入門 - よりセキュアな方法への変遷