Electron contextBridge
Electron 앱은 Node.js가 돌아가는 메인 프로세스와 웹 페이지가 돌아가는 렌더러 프로세스로 나뉜다. 문제는 렌더러에서 Node.js API에 접근할 수 있으면 보안이 뚫린다는 것이다. 악성 스크립트가 require('fs')로 파일 시스템을 건드리거나, require('child_process')로 시스템 명령어를 실행할 수 있다. 실제로 Electron 초기에는 nodeIntegration: true가 기본값이어서 이런 공격이 가능했고, 여러 보안 사고가 발생했다.
그래서 Electron은 contextIsolation이라는 개념을 도입했다. 렌더러의 JavaScript 컨텍스트를 완전히 격리해서, 웹 페이지 코드가 Node.js API에 직접 접근하지 못하게 만든 것이다. 하지만 격리만 하면 렌더러에서 메인 프로세스의 기능을 전혀 사용할 수 없게 된다. 여기서 contextBridge가 등장한다.
contextBridge가 해결하는 문제
격리 없이 Node.js API를 노출하면 보안이 무너지고, 완전히 격리하면 기능을 못 쓴다. contextBridge는 이 둘 사이에서 "필요한 것만 안전하게 노출"하는 다리 역할을 한다.
핵심 원칙은 최소 노출(Principle of Least Privilege)이다. 렌더러에 Node.js 전체를 넘기는 대신, 미리 정의한 함수와 값만 선별적으로 공개한다.
과거 방식과 비교
// ❌ 과거: nodeIntegration 활성화 (위험)
// main.js
new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
}
});
// renderer.js — Node.js 전체에 접근 가능
const fs = require('fs');
const { exec } = require('child_process');
fs.readFileSync('/etc/passwd'); // 파일 시스템 자유롭게 접근
exec('rm -rf /'); // 시스템 명령어 실행 가능
웹 페이지에 XSS 취약점이 하나만 있어도 공격자가 사용자 시스템을 완전히 장악할 수 있다. Electron 공식 문서에서도 이 방식을 강력하게 비권장한다.
// ✅ 현재: contextBridge로 최소 노출
// main.js
new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // 기본값 (Electron 12+)
nodeIntegration: false, // 기본값
}
});
preload 스크립트의 역할
contextBridge는 preload 스크립트 안에서만 사용한다. preload는 특수한 위치에 있다 — 렌더러 프로세스에서 실행되지만 Node.js API에 접근할 수 있는 유일한 코드다. 웹 페이지가 로드되기 전에 실행되며, contextIsolation이 켜져 있으면 웹 페이지의 JavaScript 컨텍스트와 완전히 분리된 환경에서 동작한다.
┌─────────────────────────────────────────────┐
│ 렌더러 프로세스 │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Preload │ │ 웹 페이지 │ │
│ │ (Node.js ✅) │────│ (Node.js ❌) │ │
│ │ │ contextBridge │ │
│ └──────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────┘
preload는 양쪽 세계에 걸쳐 있다. Node.js 모듈을 import할 수 있고, contextBridge를 통해 웹 페이지에 안전하게 값을 전달할 수 있다.
기본 사용법
contextBridge.exposeInMainWorld(apiKey, api)로 렌더러에 API를 노출한다.
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// 단순 값 노출
platform: process.platform,
// 함수 노출 — IPC 통신을 래핑
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveData: (data: string) => ipcRenderer.invoke('file:save', data),
// 이벤트 리스너 등록
onUpdateAvailable: (callback: () => void) => {
ipcRenderer.on('update-available', callback);
},
});
// renderer (웹 페이지)
// window.electronAPI로 접근 가능
const platform = window.electronAPI.platform;
const filePath = await window.electronAPI.openFile();
exposeInMainWorld의 첫 번째 인자 'electronAPI'가 window 객체에 추가되는 키 이름이다. 두 번째 인자 객체의 프로퍼티들만 렌더러에서 접근할 수 있다.
내부 동작 원리
contextBridge가 단순히 객체를 복사하는 것처럼 보이지만, 내부적으로는 훨씬 복잡한 일이 벌어진다.
구조화된 복제 (Structured Clone)
노출된 객체는 원본의 참조가 아니라 구조화된 복제(structured clone)를 통해 전달된다. 이것이 핵심 보안 메커니즘이다.
// preload.ts
const secret = { key: 'abc123', internal: new Map() };
contextBridge.exposeInMainWorld('api', {
getKey: () => secret.key, // 값만 복제됨
});
// renderer.js
window.api.getKey(); // 'abc123' — 복제된 값
// secret 객체 자체에는 접근 불가
// Map 같은 복제 불가능한 타입은 전달 안 됨
이렇게 하면 렌더러 코드가 preload의 내부 상태를 오염시키거나 프로토타입 체인을 변경하는 공격(prototype pollution)을 할 수 없다.
프록시 래핑
노출된 함수는 프록시로 래핑된다. 렌더러에서 호출하면 격리된 컨텍스트 경계를 넘어 preload 쪽에서 실행되고, 반환값이 다시 구조화된 복제를 통해 렌더러로 전달된다.
렌더러 호출 → [컨텍스트 경계] → preload 함수 실행 → [컨텍스트 경계] → 결과 반환
(인자 복제) (반환값 복제)
이 때문에 전달할 수 없는 타입이 있다:
// ❌ 전달 불가능한 것들
contextBridge.exposeInMainWorld('api', {
getMap: () => new Map(), // Error: Map은 structured clone 불가
getWeakRef: () => new WeakRef({}), // Error
callback: (fn) => fn(), // ⚠️ 함수를 인자로 받을 때 주의 필요
});
전달 가능한 타입
contextBridge를 통해 전달할 수 있는 타입은 structured clone 알고리즘이 지원하는 타입과 함수다:
| 타입 | 전달 가능 | 비고 |
|---|---|---|
| string, number, boolean | ✅ | 원시 타입 |
| null, undefined | ✅ | |
| Array | ✅ | 내부 값도 전달 가능해야 함 |
| Object (plain) | ✅ | 프로토타입은 유지 안 됨 |
| ArrayBuffer, TypedArray | ✅ | 바이너리 데이터 |
| Date | ✅ | |
| Function | ✅ | 프록시로 래핑됨 |
| Map, Set | ❌ | structured clone 미지원 |
| WeakMap, WeakRef | ❌ | |
| Symbol | ❌ | |
| 클래스 인스턴스 | ⚠️ | plain object로 변환됨 |
IPC와의 조합 패턴
contextBridge 단독으로는 렌더러 ↔ 메인 프로세스 간 통신이 안 된다. ipcRenderer를 contextBridge로 래핑하는 것이 표준 패턴이다.
단방향: 렌더러 → 메인
렌더러에서 메인으로 메시지를 보내기만 하는 경우. 응답이 필요 없을 때 사용한다.
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
sendLog: (message: string) => ipcRenderer.send('log:write', message),
minimize: () => ipcRenderer.send('window:minimize'),
});
// main.ts
ipcMain.on('log:write', (event, message) => {
logger.info(message);
});
양방향: 렌더러 ↔ 메인 (invoke/handle)
응답이 필요한 경우. invoke는 Promise를 반환하므로 await로 결과를 받을 수 있다.
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (path: string) => ipcRenderer.invoke('file:read', path),
getAppVersion: () => ipcRenderer.invoke('app:version'),
});
// main.ts
ipcMain.handle('file:read', async (event, filePath) => {
return fs.readFile(filePath, 'utf-8');
});
ipcMain.handle('app:version', () => app.getVersion());
// renderer
const content = await window.electronAPI.readFile('/config.json');
메인 → 렌더러 (이벤트 수신)
메인 프로세스에서 렌더러로 일방적으로 이벤트를 보내는 경우.
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
onDeepLink: (callback: (url: string) => void) => {
ipcRenderer.on('deep-link', (_event, url) => callback(url));
},
removeDeepLinkListener: () => {
ipcRenderer.removeAllListeners('deep-link');
},
});
// main.ts
app.on('open-url', (event, url) => {
mainWindow.webContents.send('deep-link', url);
});
보안 주의사항
contextBridge를 사용한다고 자동으로 안전해지는 것이 아니다. 잘못 사용하면 보안 구멍이 그대로 생긴다.
ipcRenderer를 통째로 노출하지 마라
// ❌ 절대 하지 말 것
contextBridge.exposeInMainWorld('electron', {
ipcRenderer: ipcRenderer,
});
// 공격자가 아무 채널이나 호출 가능
window.electron.ipcRenderer.invoke('file:delete', '/important/data');
ipcRenderer를 통째로 노출하면 contextBridge를 쓰는 의미가 없다. 렌더러에서 모든 IPC 채널에 자유롭게 메시지를 보낼 수 있기 때문이다.
// ✅ 필요한 함수만 래핑해서 노출
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
// 이것만 호출 가능. 다른 IPC 채널은 접근 불가
});
인자 검증
메인 프로세스에서 IPC 핸들러의 인자를 반드시 검증해야 한다. 렌더러에서 오는 데이터는 항상 신뢰할 수 없다.
// main.ts
ipcMain.handle('file:read', async (event, filePath: unknown) => {
// 타입 검증
if (typeof filePath !== 'string') {
throw new Error('Invalid path');
}
// 경로 탈출 방지
const resolved = path.resolve(ALLOWED_DIR, filePath);
if (!resolved.startsWith(ALLOWED_DIR)) {
throw new Error('Access denied');
}
return fs.readFile(resolved, 'utf-8');
});
콜백 함수 관리
이벤트 리스너를 등록하는 API를 노출할 때는 반드시 해제 수단도 함께 제공해야 한다. 그렇지 않으면 메모리 누수가 발생한다.
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
onProgress: (callback: (percent: number) => void) => {
const handler = (_event: IpcRendererEvent, percent: number) => callback(percent);
ipcRenderer.on('download:progress', handler);
// cleanup 함수 반환
return () => {
ipcRenderer.removeListener('download:progress', handler);
};
},
});
// renderer
const cleanup = window.electronAPI.onProgress((percent) => {
progressBar.style.width = `${percent}%`;
});
// 필요 없어지면 해제
cleanup();
TypeScript 타입 선언
TypeScript에서는 window.electronAPI의 타입을 선언해야 자동완성과 타입 검사가 동작한다.
// src/types/electron.d.ts
export interface ElectronAPI {
platform: string;
kisAgentUrl: string;
openFile: () => Promise<string>;
saveData: (data: string) => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
이 파일을 tsconfig.json의 include에 포함시키면 모든 렌더러 코드에서 window.electronAPI를 타입 안전하게 사용할 수 있다.
contextIsolation이 꺼져 있으면?
contextIsolation: false일 때 contextBridge를 사용하면 경고가 발생하고, API가 직접 window에 주입된다. 이 상태에서는 보안 보장이 없다:
- 렌더러 코드가 preload의 전역 변수에 접근 가능
- 프로토타입 오염 공격에 노출
require가 렌더러에서 사용 가능해질 수 있음
Electron 12부터 contextIsolation의 기본값이 true로 변경되었다. 특별한 이유가 없다면 절대 끄지 말아야 한다.
정리
| 개념 | 설명 |
|---|---|
| contextIsolation | 렌더러의 JS 컨텍스트를 preload와 분리 |
| contextBridge | 분리된 컨텍스트 사이에 안전한 API 다리 |
| preload | Node.js에 접근 가능한 유일한 렌더러 코드 |
| exposeInMainWorld | window 객체에 API를 노출하는 메서드 |
| structured clone | 값을 복제해서 전달, 참조 공유 차단 |
contextBridge의 존재 이유는 단순하다. "렌더러에 필요한 기능은 제공하되, Node.js 전체를 넘기지 않는다." 이 원칙을 지키면 XSS가 발생하더라도 공격자가 할 수 있는 일이 노출된 API 범위로 제한된다. 공격 표면을 최소화하는 것이 Electron 보안의 핵심이다.