はじめに
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');letmainWindow=null;constCreateWindow=()=>{mainWindow=newBrowserWindow({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><metacharset="UTF-8"><title>Test</title></head><body><buttonid="button1">test1</button></body><script type = "text/javascript">//適当なプログラムconstelectron=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が使えないのでそれすら取得できません。
どうしましょう。
巷の情報
検索して出てくる情報は以下のようなものが多いです。
nodeIntegration: true
にすればよい。セキュアにするには
nodeIntegration: false
のままにすべし。その代わりpreloadを使おう。
preload内で準備したオブジェクトや関数をレンダラープロセスのjsで使うためには、(
global
や)window
の変数に追加することでインスタンスを渡そう。あるversion以降、プロセス間で
window
が同一のオブジェクトではなくなった。よって受け渡しできない。同一オブジェクトにするにはcontextIsolation: false
としよう。いやいや、セキュアにするには
contextIsolation: true
のままにしよう。contextBridge
を使えば、nodeIntegration: false,contextIsolation: true
でもIPC通信できる[^1][^2]。
巡り巡って、どうやら、7番の方法で解決みたいですが、それ以前の手立ても含めて以下にまとめていきます。
方法1(情報1): nodeIntegration: true
nodeIntegrationというのは、メインプロセスでウィンドウを生成するとき位のオプションで指定します。先のmain.jsにおいて、BrowserWindow
の生成部分のコードを以下の様に書き替えます。
/* main.js, case 1 */// ~略~ //constCreateWindow=()=>{mainWindow=newBrowserWindow({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');letmainWindow=null;constCreateWindow=()=>{mainWindow=newBrowserWindow({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">//適当なプログラムconstbutton1=document.getElementById("button1");button1.addEventListener("click",(e)=>{window.MyIPCSend("good job");});</script></html>
まず、main.jsでは、BrowserWindow
の生成のoptionにpreload
とcontextIsolation
の項目を追加しています。また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=newBrowserWindow({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">//適当なプログラムconstbutton1=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はライト勢なので、間違いもたくさんありそう。ご指摘いただければ大変嬉しいです。