はじめに
こんにちは!0yu(@denham95173179)です。
この記事はLOCAL Students Advent Calendar 2021への寄稿です。
adventar.org
先日、 SC4Y ('21#4) IT・情報系 北海道まったりLT大会に登壇させていただき、最近細々とElectronで作っているマークダウンエディタについてお話してきました。
sc4y.connpass.com
GitHubはこちら
github.com
今回の実装でやりたかったことは以下のようなことでした。
- レンダラープロセスからのファイル操作を実装する
- Electronでファイル操作を扱うdialogモジュールをレンダラプロセス側からIPC通信で呼び出す
- Node.jsによるファイルの読み書きをレンダラープロセス側で行なう
Electron13まではremoteモジュールを採用したプロセス間通信が可能でしたが、remoteモジュール*1に依存しないセキュアなIPC通信を実装したいというのが本記事の趣旨です。
何が問題なのか
remoteモジュールの削除は、破壊的変更としてElectron14から完全に削除されてしまいました。 www.electronjs.org
remoteモジュールに依存せず、レンダラープロセス側でCommonJSを記述できるようにするためには、nodeIntegration
をtrue
にする必要があります。
しかしながら、レンダラープロセス側からNode.jsを扱うことは本来非推奨とされてします。
// background.ts const mainWindow = new BrowserWindow({ webPreferences: { nodeIntegration: true, nodeIntegrationInWorker: true } }) mainWindow.loadURL('https://example.com')
Electronでは、 セキュリティ推奨事項のチェックリストが公式ドキュメントに記載されており、チェックリスト中の2) Do not enable Node.js Integration for Remote Contentにこれが当たります。
This recommendation is the default behavior in Electron since 5.0.0.
It is paramount that you do not enable Node.js integration in any renderer (BrowserWindow, BrowserView, or
) that loads remote content. The goal is to limit the powers you grant to remote content, thus making it dramatically more difficult for an attacker to harm your users should they gain the ability to execute JavaScript on your website. After this, you can grant additional permissions for specific hosts. For example, if you are opening a BrowserWindow pointed at > https://example.com/, you can give that website exactly the abilities it needs, but no more.
これはなぜかというと、ブラウザ側(レンダラープロセス)でNode.jsのAPIを扱うメインプロセス側の処理を行なえるということは、例えばXSS攻撃を受けたとき、ユーザーのローカルファイルに無制限にアクセスされてしまうリスクがあるためです。
zenn.dev
このようなリスクを未然に防ぐために、安全なプロセス間通信を行うことが推奨されています。
すなわちremoteモジュールの削除された現行バージョンのElectronで、nodeIntegration
を無効にしたIPC通信を行う必要が生じます。
じゃあどうすればいいの?
ここで行なうべきことは大きく以下のようになります。
- background.tsで
nodeIntegration
を無効にする(レンダラープロセス側からのNode.jsモジュールの操作を無効にする) - background.tsで
contextBridge
を有効にする(コンテキストの分離*2)
※ Electron16 + Vue3 + TypeScriptを想定
background.tsのnodeIntegration
を無効にします。
// background.ts const mainWindow = new BrowserWindow({ webPreferences: { + nodeIntegration: false, + preload: path.join(__dirname, 'preload.js') } }) mainWindow.loadURL('https://example.com')
またここで、ビルド段階でpreloadのパスを読ませるため、vue.config.jsに下記のように記述します。
// vue.config.js module.exports = { pluginOptions: { electronBuilder: { + preload: 'src/preload.js', } } }
srcディレクトリ直下にpreload.tsを作成し、以下のように記述します。
これによってpreload.tsは、グローバルなwindow
オブジェクトをレンダラーと共有できるようになります。
// preload.ts const { contextBridge } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { loadPreferences: () => ipcRenderer.invoke('load-prefs') })
// Main.vue window.electronAPI.loadPreferences() // ここでwindow.electronAPI.hogehoge()などする
次にrenderer.d.ts
を作成し、以下のように記述することで、Window
インターフェースの拡張を行うことができます。
export interface IElectronAPI { loadPreferences: () => Promise<void>, } declare global { interface Window { electronAPI: IElectronAPI } }
以上のようにすることで、レンダラープロセス側でスクリプトを書く際に、グローバルなwindow
オブジェクトにアクセスできるようになります。
フォルダ構造は下記のようになっています。
├── dist_electron/ │ ├── bundled/.. # where webpack outputs compiled files │ ├── [target platform]-unpacked/.. # unpacked Electron app (main app and supporting files) │ ├── [application name] setup [version].[target binary (exe|dmg|rpm...)] # installer for Electron app │ ├── index.js # compiled background file used for electron:serve │ └── ... ├── public/ # Files placed here will be available through __static or process.env.BASE_URL ├── src/ │ ├── background.[js|ts] # electron entry file (for Electron's main process) │ ├── [main|index].[js|ts] # your app's entry file (for Electron's render process) │ └── ... ├── package.json # your app's package.json file ├── ...
おわりに
ファイル操作の実装の詳細や、contextBridgeの中身について深堀った記事を(余力があれば)かきたいです。