LoginSignup
114
91

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-09-22

2021/09/22にリリースされた v.15.0.0 中でIPC通信周りに大きな変更はありませんでした。
ただ、その前のバージョン(14.0.0)で、contextBridge.exposeInMainWorld(apiKey, api)からExperimental(実験的)が取れ、正式運用となっていたようです(#30011)。

Electron における IPC 通信

Electron で Desktop アプリケーションを作るにあたって理解しなければならないのは、根幹を成す「IPC通信」かと思います。IPC は Inter-Process Communication、プロセス間通信の略です。

IPC 通信の方法については、Electron において、いくつかの段階を経て、進化を遂げています。今回の記事では、その歴史を追いながら、仕組みとセキュアな方法を書いていきたいと思います。

なぜ IPC 通信を使うのか? Electron のベースは、Chromium のため

まずは、なぜ IPC 通信を使うのか?

Electron は、Chromium を使用しているため、Chromium のマルチプロセスアーキテクチャも使用されます。そのため Electron の各 Webページはそれぞれのプロセスで実行され、process というオブジェクトを持って処理を行っています。

Electron アプリケーションアーキテクチャ - Electron 公式ドキュメント
https://www.electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes

Electron のフレームワークの一部である、Node.js も、スレッドで処理するには、どうしても重い処理などはデッドロックを回避できません。そこで Node.js は、ネットワークアプリケーションにおいてすべての処理を「プロセス」として非同期で処理するというノンブロッキング I/O とイベントループという仕様が採用されました。

そのため、Node.js ベースの、Electron は、非同期の「プロセス間通信」の仕様がそのまま採用されています。

メインプロセスと、レンダラープロセス

Electron には「メインプロセス」と「レンダラープロセス」という二種類のプロセスを持っています。それぞれをざっとおさらいしておきましょう。

メインプロセス

アプリケーションが開始されてから終了されるまでを制御します。1アプリケーションにつき、1プロセスしかなく、メインプロセスが複数のレンダラープロセスと通信を行うことでアプリケーションを制御します。また、各OSのネイティブ要素(Node.jsモジュール)の管理も担当します。

逆を言えば、レンダラープロセスから、アプリケーションの終了はできないし、OSのネイティブ要素に直接アクセスすることはできません。

メインプロセス (main process) - Electron 公式ドキュメント
https://www.electronjs.org/docs/glossary#%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9-main-process

レンダラープロセス

レンダラープロセスは、メインプロセスとちがって複数作ることができます。

主に「表示」や「GUI などのインターフェース」を担当します。レンダラープロセス間同士での通信は行えず、常にメインプロセスを通じて通信することになります。

最初期: 今までやってきたレンダラープロセスからメインプロセスへの IPC 通信

Electron 初期の頃に行われていた通信方法です。

まず、前提として、いまこの方法でのレンダラープロセスからメインプロセスへの通信を行うには、レンダラープロセスで Electron や、他の Node.js モジュールを動作させるための「設定」をわざわざ行わないといけません。

メインプロセスで、レンダラープロセス(表示側のウェブページ)を読み込むところで nodeIntegration=true としてあげる必要があります。

main.js
mainWindow = new BrowserWindow({
  width: 1024,
  height: 640,
  webPreferences: {
    nodeIntegration: true /** ココ **/
  },
});
// HTMLファイルをロードする
mainWindow.loadURL(`file://${__dirname}/index.html`)

nodeIntegration は、Electron v.5 からデフォルトが false となってしまったので、プログラマー側で有効 true にしてやる必要があります。なぜ、デフォルトが、false となってしまったのかは、セキュリティの問題からですが、これについては別の IPC 通信方法を提示しつつ後述します。

ipcRendererから、ipcMainへのIPC通信

非同期で通信する方法で、メインプロセスのコード例は以下の通りです。

main.js
const { ipcMain } = require('electron')
ipcMain.on('asynchronous-message', (event, arg) => {  // channel名は「asynchronous-message」
  console.log(arg)  // "ping"を表示
  event.reply('asynchronous-reply', 'pong')
})

そして、通信先であるレンダラープロセス側のコード例は以下の通りです。

index.js
// レンダラープロセス(ウェブページ)
const { ipcRenderer } = require('electron')
ipcRenderer.on('asynchronous-reply', (event, arg) => {
  console.log(arg) // "pong"を表示
})
ipcRenderer.send('asynchronous-message', 'ping')  //  channel名は「asynchronous-message」

上の例は、非同期通信の例ですが、もし同期通信を行いたい場合は、send() の部分を sendSync() に書き換えれば実現できます。

ただし、同期通信にした場合、メインプロセスが呼ばれている間は レンダラープロセス上の処理は完全にブロックされます。メインプロセスからの応答があるまでは、レンダラープロセス側の操作画面はいわゆるフリーズしたような状態になります。描画処理も止まるので、ローディング画面のような CSS アニメーションも容赦なく固まります。

ということから、よっぽどの理由がない限りは、非同期通信を使うことをオススメします。

ipcMain から、ipcRendererへのIPC送信

当然ですが、ipcMain から、ipcRenderer へデータ送信することもできます。

webContents.send(channel, ...args)
https://www.electronjs.org/docs/api/web-contents#contentssendchannel-args

イメージとしては、以下の図の通りです。

メインプロセス側のコードは、

main.js
// メインプロセス
const { app, BrowserWindow } = require('electron')
let win = null

app.whenReady().then(() => {
  win = new BrowserWindow({ width: 800, height: 600 })
  win.loadURL(`file://${__dirname}/index.html`)
  win.webContents.on('did-finish-load', () => {
    win.webContents.send('ping', 'whoooooooh!') // レンダラープロセスへsendしています
  })
})

受けるレンダラープロセス側はこう書きます。

index.html
<html>
<body>
  <script>
    require('electron').ipcRenderer.on('ping', (event, message) => {
      console.log(message) // 'whoooooooh!' と出力される
    })
  </script>
</body>
</html>

二段階目: ipcRenderer.invoke() を使う

前段、メインプロセスとレンダラープロセスを直接する通信方法から、よりセキュアな方法として考えられたのが、ipcRenderer.invoke() を使う方法です。

Electron v.7 から追加になったメソッドです。もともとは、remote モジュールを使うことでレンダラープロセス内であたかもメインプロセス内のオブジェクトを直接触れるように見せかけることができた仕組みを安全に置換したものです。

ちなみに、remote モジュールは、Electron v.12 では非推奨になり、@electron/remote に置き換わりました。ソースコードの書き換えを要求されているので、ほとんど廃止に近い措置です。

参考:[Electron] IPC には新しい ipcRenderer.invoke() メソッドを使ったほうが便利 (v7+) - Qiita
https://qiita.com/jrsyo/items/abe19dff2d950132d9cd

そこで、ipcRenderer.invoke() メソッドを使った例です。

index.js
// レンダラープロセス
ipcRenderer.invoke('some-name', someArgument).then((result) => {
  // ...
})
main.js
// メインプロセス
ipcMain.handle('some-name', async (event, someArgument) => {
  const result = await doSomeWork(someArgument)
  return result
})

こうすることで、ipcRenderer.on() といった受け側を作る必要がなく、メインプロセスから Promise のデータで受け取れるようになるので便利です。受けるメインプロセスが、ipcMain.on() ではなく、ipcMain.handle() になっていることに注意してください。

なお、参考先の例にあるように、

index.js
// renderer から Main プロセスを呼び出す
const data = await ipcRenderer.invoke('invoke-test', 'ping')
console.log(data)

と、ワンライナーで書くこともできます。

最新の方法: セキュアなIPC通信 contextBridge を使う

ただ、これでもセキュアな IPC 通信には足りません。

レンダラープロセスでも Electron API や Node.js モジュールを使う前提のコード例を書いてきましたが、実際には、Electron v.5 からデフォルトが、nodeIntegration=false となったので、将来的にはレンダラープロセス側で ipcRenderer.on() といった処理が行えなくなるかもしれません。

そこで、よりセキュアな IPC 通信を行うために追加されたのが、contextBridge です。

contextBridge - Electron公式ドキュメント
https://www.electronjs.org/docs/api/context-bridge#contextbridgeexposeinmainworldapikey-api

公式ドキュメントでは、

分離されたコンテキスト間に、安全、双方向で同期されたブリッジを作成します

と、分かりにくい説明がされていますが、分かりやすく図にすると以下の通りです。

ようは、メインプロセスにある Electron API や、Node.js モジュールをレンダラープロセスで扱わせずに、メインプロセスの外部にAPIとして別途定義して、それを関数のように呼ぶという方法です。

なお、contextBridge.exposeInMainWorld(apiKey, api) については、v.14.0.0から、公式ドキュメントにおいて、Experimental(実験段階) が取れ、正式採用されるようになりました(#30011)。

実際、v12において、

Allowed ContextBridge exposeInMainWorld method to expose non-object APIs. #26834

と、オブジェクト型でないAPIも許可するようになりました。

これにはドキュメントにあるように、以下の考えから来ています。

メインプロセスとレンダラープロセスの違い - Electron公式ドキュメント
https://www.electronjs.org/docs/tutorial/application-architecture#%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%81%A8%E3%83%AC%E3%83%B3%E3%83%80%E3%83%A9%E3%83%BC%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9%E3%81%AE%E9%81%95%E3%81%84

ウェブページでは、ネイティブ GUI 関連の API を呼び出すことは許可されていません。これは、ウェブページがネイティブ GUI リソースを管理することは非常に危険であり、リソースをリークさせるのは容易いからです。 ウェブページで GUI 操作を実行する場合、ウェブページのレンダラープロセスはメインプロセスと通信して、メインプロセスがそれらの操作を実行するよう要求する必要があります。

さらにもう一歩!: contextBridge を使ったからといって必ずしも安全は担保できない

今や contextBridge も安全な方法とは言えないようです。つまり、contextBridge を使えば、すべて安心というわけでもないのです。

参考:ElectronでcontextBridgeによる安全なIPC通信(受信編) - Qiita
https://qiita.com/pochman/items/62de713a014dcacbad68

にあるように、汎用的な書き方をするとダメなようです。

const { contextBridge, ipcRenderer} = require("electron");
contextBridge.exposeInMainWorld(
  "api", {
    send: (channel, data) => { // レンダラーからの送信用
      ipcRenderer.send(channel, data);
    },
    on: (channel, func) => { // メインプロセスからの受信用
      ipcRenderer.on(channel, (event, ...args) => func(...args));
    }
  }
);

参考:Electronのセキュリティについて大きく誤認していたこと - Qiita
https://qiita.com/sprout2000/items/2b65f7d02e825549804b

ここに書いてあるとおり、上記のコードの書き方は、Electron 公式ドキュメントによれば、
https://www.electronjs.org/docs/api/context-bridge#contextbridgeexposeinmainworldapikey-api

// ❌ Bad code
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})

という書き方は、unsafe だそうです。

曰く、

いかなる種類の引数フィルタリングなしで強力なAPIを直接公開しています。これにより、どんなウェブサイトでも、可能にしてほしくない任意のIPCメッセージを送信することができるようになります。IPCベースのAPIを公開する正しい方法は、代わりにIPCメッセージごとに1つのメソッドを提供することでしょう

とのこと。

// ✅ Good code
contextBridge.exposeInMainWorld('myAPI', {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})

つまり、1つの IPC 通信につき、1処理とした方が良いでしょう。

前の図にも描きましたが、レンダラープロセスからは preload.js に登録されている関数を呼び、preload.js の中で、IPC 通信を行うというのが安全ということになります。

v12から、worldSafeExecuteJavaScript: ture になった

ちなみに Electron の v12から、BrowserWindowoptions にある、webPreferences: { worldSafeExecuteJavaScript: true } がデフォルト値になりました。基本的に false は推奨されません。

ドキュメントによると、webFrame.executeJavaScript で受け渡されるJavaScriptコードの値が安全に引き渡されるようにサニタイズされると書いてありますが、具体的にどう行われるかは書いてありません。そもそも、webFrame.executeJavaScript 自体がイレギュラーっぽい使い方なので、避けておいた方が良いかもしれません。

BrowserWindow を作るときは、以下のような webPreferencesのオプション設定にすると良いでしょう。

main.js
  let mainWindow = new BrowserWindow({
    title: config.name,
    width: 1024,
    height: 640,
    minWidth: 1024,
    minHeight: 640,
    webPreferences: {
      // v12 からデフォルト=true になった
      worldSafeExecuteJavaScript: true,
      // XSS対策としてnodeモジュールをレンダラープロセスで使えなくする
      nodeIntegration: false,
      // レンダラープロセスに公開するAPIのファイル
      // v12 からデフォルト=true になった 
      contextIsolation: true,
      preload: __dirname + '/preload.js'
    }
  });

けっきょく contextIsolation: ture にしておくべきか

v12 から、デフォルトで true となりました。そうするべきでしょう。

コンテンツセキュリティポリシー(CSP)警告を回避する

いつからか、以下のような警告が出るようになりました。

CSP.png

どうやら、コンテンツセキュリティポリシーに則っていないというブラウザ側からの警告のようです。

コンテンツセキュリティポリシー (CSP)
https://developer.mozilla.org/ja/docs/Web/HTTP/CSP

これは、ページ内で JavaScript を書いたときに、クロスサイト・スクリプティング攻撃(XSS) やデータインジェクションの脆弱性を入れてしまう可能性があるということで警告が出ているようです。

特にそういったアプリケーションを作らないのであれば、無視しても問題なさそうですが、気になるようであれば、以下のように該当のページの <meta> タグに、

index.html
<meta http-equiv="Content-Security-Policy" content="default-src 'self';" />

を追加してあげると良いでしょう。

ただ、これをしてしまうと、

index.html
<script type="text/javascript">
  // なんらかのスクリプト
</script>

というふうに、HTML ファイルに直接、JavaScript を書くことができなくなります(今度は明確なエラーを吐きます)。

ですので、書いていたスクリプトを index.js とか適当なファイルを作って、外へ避けてあげれば良いでしょう。

index.html
<script src="index.js"></script>

これで、コンテンツセキュリティポリシーに関する警告は消えてくれます。

サンプルプログラム

これら、セキュアな IPC 通信のサンプルプログラムを作成しました。「3分間タイマー」という、いかにも単純なアプリですが、nodeIntegration=false かつ、contextBridge を使って実装されています。

hibara/SimpleTimer - GitHub
https://github.com/hibara/SimpleTimer

preload.js
const { contextBridge, ipcRenderer} = require("electron");

contextBridge.exposeInMainWorld(
  "api", {
    // タイマーの開始
    TimerStart: () =>
        ipcRenderer.invoke("ipc-timer-start")
            .then(result => result)
            .catch(err => console.log(err)),
    // タイマーの停止
    TimerStop: () => ipcRenderer.send("ipc-timer-stop"),
    // タイマーのリセット(ミリ秒を最大の初期値へ戻す)
    TimerReset: () => ipcRenderer.send("ipc-timer-reset"),
    // 現在のタイマーの値(ミリ秒)をレンダラープロセスへ投げて表示させる
    DisplayTimer: (listener) => {
      ipcRenderer.on("ipc-display-timer", (event, arg) => listener(arg));
    }
    // send: (channel, data) => { // レンダラーからの送信用
    //   ipcRenderer.send(channel, data);
    // },
    // on: (channel, func) => { // メインプロセスからの受信用
    //   ipcRenderer.on(channel, (event, ...args) => func(...args));
    // }
  }
);

この上記コードでは、"api"を key として設定しています。ですので、呼び出す側(レンダラープロセス)の index.html では以下のように呼び出します。

また、コメント欄からもご指摘いただいたように、各関数にチャネル名を含める必要はありません(すべて preload.js で書かれているため)。

index.html
<!DOCTYPE html>
<html lang="ja">
<head><meta charset="UTF-8">
  <title></title>
  <link rel="stylesheet" href="style.css">
  <script type="text/javascript">
    window.onload = () => {
      // 表示の初期化
      window.api.TimerReset();  // contextBridge
      // 「開始」ボタンをクリック
      document.getElementById('button-start').addEventListener('click', async () => {
        const result = await window.api.TimerStart(); // contextBridge
        if (result === true) {
          document.getElementById('button-reset').textContent = "停止";
        }
        else {
          document.getElementById('button-reset').textContent = "リセット";
        }
      });
      // 「リセット」or「停止」ボタンをクリック
      document.getElementById('button-reset').addEventListener('click', async () => {
        if ( document.getElementById('button-reset').textContent === "停止" ) {
          document.getElementById('button-reset').textContent = "リセット";
          window.api.TimerStop(); // contextBridge
        }
        else {
          window.api.TimerReset();  // contextBridge
        }
      });
    }
    // タイマー(ミリ秒)の受け取り
    window.api.DisplayTimer((milliseconds) => { // contextBridge
      // console.log("ipc-display-timer: " + arg);
      if (milliseconds <= 0){
        document.getElementById('button-reset').textContent = "リセット";
      }
      let min = parseInt((milliseconds / 1000) / 60);
      let sec = parseInt(milliseconds / 1000) % 60;
      document.getElementById('timer-number').textContent =
          ('00' + min).slice(-2) + ':' + ('00' + sec).slice(-2);
    });
  </script>
</head>
<body>

<div>
  <div id="timer-number"><!-- 3:00 --></div>
</div>

<div class="button-wrapper">
  <button id="button-start" class="button button-color-blue">開始</button>
  <button id="button-reset" class="button button-color-red">リセット</button>
</div>

</body>
</html>

window.api.TimerStart(channel) や、window.api.TimerStop(channel) のように、設定した "api" キーを通して関数を呼んでいます。

ソースコードではなく、サンプルアプリケーションの動作だけ見たいという方は以下から、実行ファイルのみをダウンロードできます。

macOS:
https://github.com/hibara/SimpleTimer/releases/download/v1.0.8/SimpleTimer-darwin-x64.zip

Windows:
https://github.com/hibara/SimpleTimer/releases/download/v1.0.8/SimpleTimer-win32-ia32.zip

参考

ipcRenderer.invoke()
https://qiita.com/jrsyo/items/abe19dff2d950132d9cd

ElectronでcontextBridgeによる安全なIPC通信(受信編)
https://qiita.com/pochman/items/62de713a014dcacbad68

114
91
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
114
91