Electronのセキュリティ設計: preload・contextBridge・IPCを狭くする
Electronでrenderer、preload、mainの信頼境界を設計し、必要最小限のIPCだけを公開する方法を解説します。
セキュリティ境界はウィンドウではなく権限の流れだ
Electronアプリはmain、preload、rendererという三つの実行面を持ちます。rendererは表示するHTML・JavaScriptで、外部入力やXSSに最も近い面です。mainはファイル、OS、ウィンドウ、更新を扱えます。preloadは両者の間に置く、狭い橋です。安全性は「別プロセスがある」だけで得られず、rendererからmainへ渡る能力を一つずつ名前付きで絞ることで得られます。
ユーザー生成HTML、リッチテキスト、Webページ、外部ログイン画面を扱うなら、rendererは信頼しない入力を実行する場所として考えます。そこでNode.jsをそのまま開くと、画面の脆弱性がローカルファイル、子プロセス、認証情報への到達経路になります。
renderer (信頼しない表示層)
└─ preloadが公開した型付きAPIだけを呼ぶ
└─ IPCの許可済みコマンド
└─ mainが入力を検証してOS APIを実行
ウィンドウの初期値をコードで明示する
セキュリティ設定を既定値や過去のテンプレートに任せず、各ウィンドウで明示します。nodeIntegration: false、contextIsolation: true、sandbox: trueを基本にし、不要な能力を追加しません。
// main.ts
import { BrowserWindow } from 'electron';
import path from 'node:path';
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true
}
});
webSecurity: falseはCORS等の開発上の問題を隠すための修正ではありません。原因をWeb API、開発サーバー設定、テスト用プロキシで直します。署名済みの本番アプリへ緩和設定を運ぶと、問題の範囲を端末まで広げます。
URL移動と新しいウィンドウも権限要求として扱う
ページ内リンク、window.open、認証リダイレクトは、表示先を変えるだけではありません。信頼していないコンテンツを同じ権限のWebContentsで開く契機です。アプリ内ナビゲーションは許可リストに絞り、外部リンクは既定ブラウザへ渡します。
import { shell } from 'electron';
const appOrigin = 'https://app.example.com';
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(appOrigin)) {
event.preventDefault();
void shell.openExternal(url);
}
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: 'deny' };
});
OAuthのようにアプリ内で必要な外部画面は、別ウィンドウ、最小権限、明示した戻りURLを設計します。URLの前方一致だけに頼らず、originとパスを正確に比較します。
preloadは機能一覧であり、IPC配管ではない
preloadはipcRendererをそのままrendererへ公開する場所ではありません。UIに必要な業務操作だけを、引数と戻り値が分かる関数として定義します。
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('desktop', {
selectExportPath: () => ipcRenderer.invoke('export:select-path'),
writeMarkdown: (input: { path: string; contents: string }) =>
ipcRenderer.invoke('export:write-markdown', input),
getAppVersion: () => ipcRenderer.invoke('app:version')
});
このAPIはsend(channel, ...args)のような汎用メッセージバスではありません。rendererがチャンネル名を選べる設計では、機能追加時に意図しないmain handlerへ届く余地が生まれます。readFile、runCommandのようなOS API名をそのまま公開せず、writeMarkdownのように用途を限定します。
renderer側では型を置き、ブラウザでのUIテストにも代替を用意します。
// src/app.d.ts
declare global {
interface Window {
desktop?: {
selectExportPath(): Promise<string | null>;
writeMarkdown(input: { path: string; contents: string }): Promise<void>;
getAppVersion(): Promise<string>;
};
}
}
export {};
イベントはコールバックではなく購読解除まで契約する
mainからrendererへ進捗を送る必要がある場合も、無制限のonを渡しません。許可するイベントを固定し、解除関数を返します。画面破棄後に古いリスナーが残る問題と、意図しないイベント受信を同時に防げます。
// preload.ts
contextBridge.exposeInMainWorld('desktop', {
onUpdateProgress: (listener: (percent: number) => void) => {
const handler = (_event: Electron.IpcRendererEvent, percent: unknown) => {
if (typeof percent === 'number' && Number.isFinite(percent)) listener(percent);
};
ipcRenderer.on('update:progress', handler);
return () => ipcRenderer.removeListener('update:progress', handler);
}
});
mainのIPC handlerは小さなサーバーエンドポイントだ
ipcMain.handleはローカルのHTTPエンドポイントと同じように扱います。認可、入力検証、エラーの公開範囲、監査可能性を考えます。rendererが渡したパスや内容を正しいと仮定しません。
// main.ts
import { dialog, ipcMain } from 'electron';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
ipcMain.handle('export:select-path', async () => {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Markdown', extensions: ['md'] }]
});
return result.canceled ? null : result.filePath;
});
ipcMain.handle('export:write-markdown', async (_event, input: unknown) => {
if (!isMarkdownExport(input)) throw new Error('Invalid export request');
if (path.extname(input.path).toLowerCase() !== '.md') {
throw new Error('Only Markdown files can be exported');
}
if (input.contents.length > 5_000_000) throw new Error('Export is too large');
await writeFile(input.path, input.contents, 'utf8');
});
function isMarkdownExport(value: unknown): value is { path: string; contents: string } {
if (!value || typeof value !== 'object') return false;
const input = value as Record<string, unknown>;
return typeof input.path === 'string' && typeof input.contents === 'string';
}
「ダイアログで一度選んだパスだけを書ける」ようにするなら、mainで選択トークンや許可済みパスを保持し、rendererが任意文字列を渡せないようにします。アプリが扱うデータに応じて、シンボリックリンク、上書き確認、ファイル名正規化、最大サイズも検討します。
チャンネル名よりコマンドモデルを先に設計する
file:save、file:openとチャンネルだけを増やす前に、ユーザーの操作を列挙します。どの画面で、何を選び、何を保存し、失敗時に何を表示するのか。その操作ごとに一つのpreload関数と一つのhandlerを対応させます。チャンネル名は実装詳細であり、APIの中心ではありません。
| 操作 | preload API | mainの責任 |
|---|---|---|
| エクスポート先選択 | selectExportPath() | OSダイアログ、キャンセルをnullへ変換 |
| Markdown出力 | writeMarkdown(input) | 型、拡張子、サイズ、書込みの検証 |
| アプリ版取得 | getAppVersion() | 安全な定数だけを返す |
リモートコンテンツを表示した瞬間、ポリシーが変わる
社内SPAだけを読み込むウィンドウと、利用規約、ヘルプ、ユーザーHTML、OAuthを表示するウィンドウは同じではありません。後者にローカルのpreload APIを与えないのが原則です。必要なら権限を持たない専用ウィンドウまたは既定ブラウザを使います。
Content Security Policyも「後で足すヘッダー」ではなく、どこからscript、style、画像、接続を許すかの設計です。インラインscriptや広いconnect-srcを例外にする前に、ビルド手順や通信先を正します。開発・本番のCSP差分は文書化し、パッケージ版で確認します。
<!-- 例: 実際の許可元は製品の要件に合わせて狭める -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: https:; style-src 'self'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'none'">
セキュリティ点検はリリース直前でなく、機能追加時に行う
機能を一つ追加するたびに、次を確認します。
- rendererにNode.js、
ipcRenderer、任意のmain APIを渡していないか。 - 新しいIPCは用途が狭く、入力の型、長さ、パス、状態をmainで検証するか。
- 外部URL、新規ウィンドウ、WebViewを明示的に扱い、権限を持つ画面へ遷移させないか。
contextIsolation、sandbox、CSP、依存Electron版を意図せず弱めていないか。- セキュリティエラーをrendererへ詳細に返しすぎず、ログには再現に必要な情報を残すか。
テストには悪意ある入力だけでなく、キャンセル、空文字、巨大ファイル、古い下書き、オフライン、更新中の再起動を入れます。製品の通常フローで境界を越える場所ほど、失敗時の表示と記録が重要です。
まとめ: 機能を隠すのではなく、権限に名前を付ける
安全なElectronアプリは、機能の少ないアプリではありません。rendererが必要な能力を、preloadの小さな名前付きAPIとして受け、mainが検証して実行するアプリです。便利な汎用橋を一本作るより、製品操作ごとの狭い橋をいくつか作る方が、機能変更、レビュー、インシデント調査を容易にします。