Chapter 06

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

Kei Touge
Kei Touge
2021.09.18に更新

前章では、デフォルトのセキュリティ設定を変更してメインプロセスとレンダラープロセスとの間の IPC 通信を実現していました。

main.js
   const mainWindow = new BrowserWindow({
     width: 600,
     height: 400,
     title: 'マイアプリ',
+    webPreferences: {
+      nodeIntegration: true,
+      contextIsolation: false,
+    },
   });

しかしながら、同じく前章の冒頭で触れた通り、レンダラープロセス側で Node.js の機能がそのまま使えたり、メインプロセスとレンダラープロセスとの間でJavaScript コンテキストを共有していたりするのは危険です。

この問題の解決策として、Electron では preload スクリプト という仕組みが用意されています。

nodeIntegration をオフにする

preload スクリプトの読み込み

preload スクリプト は、その中にアプリが必要とする IPC 通信を定義し、レンダラープロセスからはその定義済みの機能のみを利用可能とする仕組みです。いわば、レンダラープロセスのためにメインプロセスの特定機能にのみアクセスできる小窓を開けておくようなイメージです。

preload スクリプトは、BrowserWindow インスタンスの作成時にメインプロセスから読み込みます。

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'),
      },
    });
    // 省略
  };

Node.js の標準モジュールである path モジュールを読み込んでいます。標準モジュールなので、さらに何らかのパッケージをインストールする必要はありません。

path.join() メソッドは引数に与えられたすべてのパスを結合して返します。

(例)
path.join('homedir', 'public', 'index.html');

// Windows の場合
=> "\homedir\public\index.html"

// macOS などの UNIX ライクな OS の場合
=> "/homedir/public/index.html"

__dirname は、現在実行中のモジュールの絶対パスが格納された変数です。

nodeIntegration の行は削除したので、デフォルトの設定 (=false) に戻りました。

contextIsolation の値は false のままであることに注意してください。つまり、まだメインプロセスとレンダラープロセスとの間では JavaScript コンテキスト を共有しています。

preload スクリプトの作成

では、プロジェクトフォルダ直下に preload.js という名前のファイルを作成しましょう。

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

preload.js は、あくまでもメインプロセス側のスクリプトなので CommonJS で記述する必要があります。

electron モジュールからレンダラープロセスに利用させたい機能 (= ipcRenderer) をインポートしています。

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

window.openDialog = async () => {
  return ipcRenderer.invoke('open-dialog');
};

Window オブジェクトを拡張して、独自の openDialog: async () => Promise<string>; メソッドを附属させています。

そして、openDialog メソッド内部では、前章ではレンダラープロセスで実行した ipcRenderer.invoke() をここで実行し、メインプロセスからの返信を返します。

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

メインプロセスで nodeIntegration: false を設定した(デフォルトのセキュリティ設定に戻した)ので、app.js では再び CommonJS の記法は使えなくなりました。

app.js
// もう使えない(エラーになる)
- const { ipcRenderer } = require('electron');

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

 button.addEventListener('click', async () => {
   // ipcRender をインポートできなくなったので、これもエラー
-  text.textContent = ipcRenderer.invoke('open-dialog');
 });

しかしながら、まだメインプロセスとレンダラープロセスとの間で JavaScript コンテキストを共有しているため、メインプロセスが見ている Window オブジェクトとレンダラープロセスが見ている Window オブジェクトは同一のものです。

つまり、レンダラープロセスからも Window オブジェクトを拡張して附属された openDialog メソッドが見えていることになります。

app.js
 button.addEventListener('click', async () => {
   // Window オブジェクトに openDialog() メソッドが存在している
+  text.textContent = await window.openDialog();
 });

これで前章と同じ実行結果を得られるようになりました。

fig12

この章のソースコード

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
const { ipcRenderer } = require('electron');

window.openDialog = async () => {
  return 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 () => {
  // text.textContent = ipcRenderer.invoke('open-dialog');

  // Window オブジェクトに openDialog() メソッドが存在している
  text.textContent = await window.openDialog();
});