Chapter 07

セキュアな IPC 通信(contextBridge 編)

Kei Touge
Kei Touge
2021.09.18に更新

前章までで nodeIntegration: false な IPC 通信が実現できました。では、もう一つのコンテキストの分離に関する問題の解決を目指しましょう。

contextBridge の導入

Electron には、contextBridge という API も用意されています。これは、preload スクリプト 内で分離されたコンテキスト間のブリッジを構築します。

ここでも レンダラープロセスのために開けられた小窓という性質に変わりはありません。

contextBridge の構文

構文
contextBridge.exposeInMainWorld(
  'レンダラープロセスに見せる API キー名',
  {
    // レンダラープロセスに利用させるメソッド
    doThing: () => ipcRenderer.send('do-a-thing')
  }
)

これによりレンダラープロセスからは、'API キー'.doThing() というかたちでメインプロセスの機能を利用することができるようになります。

contextBridge の用法には注意が必要

たとえば以下のコードは、contextBridge を利用しているにもかかわらず危険です。

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

contextBridge.exposeInMainWorld('myAPI', {
  // 🆖 bad!
   renderer: ipcRenderer
});

なぜなら、この myAPI.renderer()ipcRenderer という強力な API を無制限かつ包括的にレンダラープロセスへ曝してしまっているからです。

公式ドキュメントより引用します:

これは、強力な API を引数のフィルタリングなしで直接公開するものです。これでは、任意のウェブサイトが、可能であってほしくない任意の IPC メッセージを送信することができてしまいます。

IPC ベースの API を公開する正しい方法は、代わりに IPC メッセージごとに 1 つのメソッドを提供することです。

「IPC メッセージ(のチャンネル)ごとに 1 つのメソッドを提供する」ように書き直すと次のようになります。

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

contextBridge.exposeInMainWorld('myAPI', {
  openDialog: async () => ipcRenderer.invoke('open-dialog'),
});

レンダラープロセスが myAPI.openDialog() というメソッドを呼び出すと、open-dialog チャンネルの範囲でのみこのブリッジを渡ることができます。

レンダラープロセス(app.js)の修正

それでは、contextBridge が提供されていることを前提に app.js を修正します。

app.js
const button = document.getElementById('button');
const text = document.getElementById('text');

button.addEventListener('click', async () => {
  /**
   * Window オブジェクトに openDialog() メソッドは **もう** 存在していない!
   * text.textContent = await window.openDialog();
   */

  // レンダラープロセスに見えているのは myAPI キーのみで、それ以外のことは分からない
  text.textContent = await window.myAPI.openDialog();
});

メインプロセスもアップデート

ついに nodeIntegration: false かつ contextIsolation: true な IPC 通信が実現できました。

main.js
 const createWindow = () => {
   const mainWindow = new BrowserWindow({
     width: 600,
     height: 400,
     title: 'マイアプリ',
     webPreferences: {
-      contextIsolation: false,
       preload: path.join(__dirname, 'preload.js'),
     },
   });
   // 省略
 };

この章のソースコード全文

package.json
package.json
{
  "name": "myapp",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "build": "electron-packager ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^13.1.2",
    "electron-packager": "^15.2.0"
  }
}
index.html
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>マイアプリ</title>
  </head>
  <body>
    <h1>Hello, world.</h1>
    <button id="button">Open</button>
    <p id="text"></p>

    <script src="app.js"></script>
  </body>
</html>
main.js
main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    width: 600,
    height: 400,
    title: 'マイアプリ',
    webPreferences: {
      // nodeIntegration: true,
      // contextIsolation: false,
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  ipcMain.handle('open-dialog', async (_e, _arg) => {
    return dialog
      .showOpenDialog(mainWindow, {
        properties: ['openFile'],
      })
      .then((result) => {
        if (result.canceled) return '';
        return result.filePaths[0];
      });
  });

  mainWindow.loadFile('index.html');
};

app.once('ready', () => {
  createWindow();
});

app.once('window-all-closed', () => app.quit());
preload.js
preload.js
const { ipcRenderer, contextBridge } = require('electron');

contextBridge.exposeInMainWorld('myAPI', {
  openDialog: async () => ipcRenderer.invoke('open-dialog'),
});
app.js
app.js
// const { ipcRenderer } = require('electron');

const button = document.getElementById('button');
const text = document.getElementById('text');

button.addEventListener('click', async () => {
  /**
   * Window オブジェクトに openDialog() メソッドは **もう** 存在していない!
   * text.textContent = await window.openDialog();
   */

  // レンダラープロセスに見えているのは myAPI キーのみで、それ以外のことは分からない
  text.textContent = await window.myAPI.openDialog();
});