junyeokk
Blog
Electron·2026. 02. 09

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 전체를 넘기는 대신, 미리 정의한 함수와 값만 선별적으로 공개한다.

과거 방식과 비교

javascript
// ❌ 과거: 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 공식 문서에서도 이 방식을 강력하게 비권장한다.

javascript
// ✅ 현재: 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 컨텍스트와 완전히 분리된 환경에서 동작한다.

text
┌─────────────────────────────────────────────┐
│              렌더러 프로세스                    │
│                                             │
│  ┌──────────────┐    ┌──────────────────┐   │
│  │  Preload      │    │  웹 페이지         │   │
│  │  (Node.js ✅) │────│  (Node.js ❌)     │   │
│  │              │ contextBridge          │   │
│  └──────────────┘    └──────────────────┘   │
│                                             │
└─────────────────────────────────────────────┘

preload는 양쪽 세계에 걸쳐 있다. Node.js 모듈을 import할 수 있고, contextBridge를 통해 웹 페이지에 안전하게 값을 전달할 수 있다.


기본 사용법

contextBridge.exposeInMainWorld(apiKey, api)로 렌더러에 API를 노출한다.

typescript
// 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);
  },
});
typescript
// renderer (웹 페이지)
// window.electronAPI로 접근 가능
const platform = window.electronAPI.platform;
const filePath = await window.electronAPI.openFile();

exposeInMainWorld의 첫 번째 인자 'electronAPI'window 객체에 추가되는 키 이름이다. 두 번째 인자 객체의 프로퍼티들만 렌더러에서 접근할 수 있다.


내부 동작 원리

contextBridge가 단순히 객체를 복사하는 것처럼 보이지만, 내부적으로는 훨씬 복잡한 일이 벌어진다.

구조화된 복제 (Structured Clone)

노출된 객체는 원본의 참조가 아니라 구조화된 복제(structured clone)를 통해 전달된다. 이것이 핵심 보안 메커니즘이다.

typescript
// 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 쪽에서 실행되고, 반환값이 다시 구조화된 복제를 통해 렌더러로 전달된다.

text
렌더러 호출 → [컨텍스트 경계] → preload 함수 실행 → [컨텍스트 경계] → 결과 반환
              (인자 복제)                              (반환값 복제)

이 때문에 전달할 수 없는 타입이 있다:

typescript
// ❌ 전달 불가능한 것들
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, Setstructured clone 미지원
WeakMap, WeakRef
Symbol
클래스 인스턴스⚠️plain object로 변환됨

IPC와의 조합 패턴

contextBridge 단독으로는 렌더러 ↔ 메인 프로세스 간 통신이 안 된다. ipcRenderer를 contextBridge로 래핑하는 것이 표준 패턴이다.

단방향: 렌더러 → 메인

렌더러에서 메인으로 메시지를 보내기만 하는 경우. 응답이 필요 없을 때 사용한다.

typescript
// 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로 결과를 받을 수 있다.

typescript
// 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');

메인 → 렌더러 (이벤트 수신)

메인 프로세스에서 렌더러로 일방적으로 이벤트를 보내는 경우.

typescript
// 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를 통째로 노출하지 마라

typescript
// ❌ 절대 하지 말 것
contextBridge.exposeInMainWorld('electron', {
  ipcRenderer: ipcRenderer,
});

// 공격자가 아무 채널이나 호출 가능
window.electron.ipcRenderer.invoke('file:delete', '/important/data');

ipcRenderer를 통째로 노출하면 contextBridge를 쓰는 의미가 없다. 렌더러에서 모든 IPC 채널에 자유롭게 메시지를 보낼 수 있기 때문이다.

typescript
// ✅ 필요한 함수만 래핑해서 노출
contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile'),
  // 이것만 호출 가능. 다른 IPC 채널은 접근 불가
});

인자 검증

메인 프로세스에서 IPC 핸들러의 인자를 반드시 검증해야 한다. 렌더러에서 오는 데이터는 항상 신뢰할 수 없다.

typescript
// 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를 노출할 때는 반드시 해제 수단도 함께 제공해야 한다. 그렇지 않으면 메모리 누수가 발생한다.

typescript
// 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의 타입을 선언해야 자동완성과 타입 검사가 동작한다.

typescript
// 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.jsoninclude에 포함시키면 모든 렌더러 코드에서 window.electronAPI를 타입 안전하게 사용할 수 있다.


contextIsolation이 꺼져 있으면?

contextIsolation: false일 때 contextBridge를 사용하면 경고가 발생하고, API가 직접 window에 주입된다. 이 상태에서는 보안 보장이 없다:

  • 렌더러 코드가 preload의 전역 변수에 접근 가능
  • 프로토타입 오염 공격에 노출
  • require가 렌더러에서 사용 가능해질 수 있음

Electron 12부터 contextIsolation의 기본값이 true로 변경되었다. 특별한 이유가 없다면 절대 끄지 말아야 한다.


정리

개념설명
contextIsolation렌더러의 JS 컨텍스트를 preload와 분리
contextBridge분리된 컨텍스트 사이에 안전한 API 다리
preloadNode.js에 접근 가능한 유일한 렌더러 코드
exposeInMainWorldwindow 객체에 API를 노출하는 메서드
structured clone값을 복제해서 전달, 참조 공유 차단

contextBridge의 존재 이유는 단순하다. "렌더러에 필요한 기능은 제공하되, Node.js 전체를 넘기지 않는다." 이 원칙을 지키면 XSS가 발생하더라도 공격자가 할 수 있는 일이 노출된 API 범위로 제한된다. 공격 표면을 최소화하는 것이 Electron 보안의 핵심이다.

관련 문서