03 · contextBridge — 좁은 함수만 노출하라

이 문서가 답하는 질문: preload에서 renderer로 무엇을 어떻게 노출해야 하나? 왜 window.api = { ... }을 직접 할당하면 안 되나? 한 줄 답: contextBridge.exposeInMainWorld는 preload(Node 컨텍스트)와 renderer(웹 컨텍스트) 사이에 단방향·복사 전용·함수만 통과시키는 보안 게이트다 — 객체 참조는 절대 새지 않는다.”


Why — window.api = ... 직접 할당의 죽음

옛날 방식 (v11 이전 / contextIsolation: false)

// preload.ts (위험한 옛날 방식)
window.electron = {
  readFile: require('fs').readFileSync,
  exec: require('child_process').exec,
};

이게 위험한가? Renderer에 떠 있는 웹 페이지가 — XSS·악성 광고·외부 스크립트 한 줄이 — window.electron.exec('rm -rf /')를 할 수 있다. require('fs') 자체가 살아서 노출된다.

더 깊은 문제는 — prototype chain이다.

// 공격자 코드 (renderer XSS)
window.electron.readFile.constructor.constructor('return process')().exit();
// → Function.constructor로 임의 코드 실행 → process 객체 접근 → 앱 종료

JavaScript의 함수는 모두 Function.constructor를 통해 임의 코드 실행 능력을 가진다. 한 함수만 노출해도 — 전체 권한이 새는 셈이다.

contextIsolation: true + contextBridge의 등장 (v12 디폴트)

Electron v12부터 디폴트로 contextIsolation: true다. 이 모드에서는 — preload와 renderer가 같은 window를 공유하지 않는다. V8의 별도 context를 가진다.

┌──────────────────────────────────────┐
│  Renderer Window                     │
│  ┌─────────────────┐  ┌────────────┐ │
│  │ Renderer Context│  │ Preload Ctx│ │
│  │  - DOM          │  │  - require │ │
│  │  - React        │  │  - ipcRend │ │
│  │  - window.x = 1 │  │            │ │
│  └─────────────────┘  └────────────┘ │
│         ↑                  │         │
│         └──── contextBridge ┘        │
│              (gate)                  │
└──────────────────────────────────────┘

window.api직접 할당하면 — *preload의 window*에 할당될 뿐, renderer는 못 본다. 두 컨텍스트를 잇는 유일한 다리contextBridge다.


How — exposeInMainWorld의 작동

// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
 
contextBridge.exposeInMainWorld('api', {
  readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
  onUpdate: (cb: (v: string) => void) => {
    const listener = (_: any, v: string) => cb(v);
    ipcRenderer.on('app:update', listener);
    return () => ipcRenderer.removeListener('app:update', listener);
  },
});
// renderer.ts — window.api로 접근
const data = await window.api.readFile('config.json');
window.api.onUpdate((v) => console.log(v));

exposeInMainWorld실제로 하는 일

  1. preload context의 객체를 깊은 복사해서 renderer context로 옮긴다.
  2. 함수는 프록시로 감싸 — 호출되면 preload context에서 실행되고 결과만 복사돼 renderer로 돌아온다.
  3. 객체 참조는 노출되지 않는다 — ipcRenderer 자체나 require 같은 살아 있는 객체는 통과 불가.
  4. Symbol·function·Promise는 통과 가능. 함수의 .constructor로 prototype을 거슬러 올라가도 — renderer context의 Function만 만난다 (preload context의 Function이 아님).
// 공격자 시도 — 더 이상 안 통한다
window.api.readFile.constructor.constructor('return process')();
// → renderer context의 Function이라 process가 없음 → undefined

노출 가능한 타입

타입통과비고
원시 (string, number, boolean)
객체 (literal)깊은 복사
배열깊은 복사
함수프록시로 감싸짐
async 함수Promise도 통과
null/undefined
Date
RegExp✅ (v11+)
Map/Set✅ (v11+)
Symbol통과 불가
Errormessage만
DOM 객체직렬화 불가
EventEmitter 인스턴스절대 노출 금지
ipcRenderer 통째절대 노출 금지

exposeInIsolatedWorld(v28+) — 더 좁은 게이트

contextBridge.exposeInIsolatedWorld(1, 'api', { ... });

특정 isolated world ID(주로 Chrome extension 시나리오)에만 노출. 일반 앱에서는 exposeInMainWorld로 충분.


What — 함수 시그니처 설계 패턴

contextBridge로 무엇을 노출할지가 앱 전체의 보안 표면을 결정한다. 좋은 시그니처와 나쁜 시그니처를 나란히 본다.

나쁨 1 — ipcRenderer 통째 노출

// ❌ 절대 금지
contextBridge.exposeInMainWorld('ipc', ipcRenderer);

이러면 — renderer가 임의 채널임의 메시지를 쏠 수 있다. ipcMain.handle('shell:exec', ...)이 어딘가에 정의돼 있으면 — XSS가 그것을 그대로 호출한다.

나쁨 2 — invoke 자체를 노출

// ❌ 마찬가지로 금지
contextBridge.exposeInMainWorld('api', {
  invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args),
});

채널 이름을 renderer가 자유롭게 정한다 — 결과적으로 위와 동일.

좋음 — 동사 단위로 함수를 노출

// ✅ 좋은 설계
contextBridge.exposeInMainWorld('api', {
  // 도메인: user
  user: {
    get: (id: string) => ipcRenderer.invoke('user:get', id),
    update: (id: string, patch: UserPatch) => ipcRenderer.invoke('user:update', id, patch),
  },
  // 도메인: fs (앱 데이터 폴더 안만)
  appData: {
    read: (filename: string) => ipcRenderer.invoke('app-data:read', filename),
    write: (filename: string, content: string) => ipcRenderer.invoke('app-data:write', filename, content),
  },
  // 도메인: app
  app: {
    getVersion: () => ipcRenderer.invoke('app:get-version'),
    quit: () => ipcRenderer.send('app:quit'),
  },
});

규칙:

  1. 함수 단위로 — 객체 통째가 아니라 한 동작 한 함수.
  2. 도메인별 그룹window.api.user.get 형태.
  3. 채널 이름은 안에 숨겨라 — renderer는 함수만 본다.
  4. 입력은 간단한 타입 — string, number, plain object. DOM 객체·함수·Class 인스턴스 금지.

안전성 비교 — 같은 기능 두 가지 방식

// ❌ 위험: 임의 경로
contextBridge.exposeInMainWorld('api', {
  readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
});
// main.ts
ipcMain.handle('fs:read', (_, path) => fs.readFile(path, 'utf8'));
// → '/etc/passwd' 전달 가능
// ✅ 안전: 앱 데이터 폴더 한정
contextBridge.exposeInMainWorld('api', {
  readUserConfig: (name: string) => ipcRenderer.invoke('user-config:read', name),
});
// main.ts
ipcMain.handle('user-config:read', (_, name) => {
  // 이름만 추출 — '../'  공격 차단
  const safe = path.basename(name);
  const full = path.join(app.getPath('userData'), 'config', safe);
  return fs.readFile(full, 'utf8');
});

함수 이름입력을 좁히는 것 — 그것이 곧 공격 표면 축소다.


What — nodeIntegration: true 시대를 떠난 이유

초기 Electron (v0.x~v4) — nodeIntegration: true가 디폴트

// 옛날 패턴
new BrowserWindow({
  webPreferences: {
    nodeIntegration: true, // 디폴트
    contextIsolation: false, // 디폴트
  },
});

이 모드에서는 renderer가 직접 require를 호출할 수 있다.

// renderer.ts (옛날)
const fs = require('fs');
const data = fs.readFileSync('/etc/passwd', 'utf8');

편리했지만 — 모든 웹 페이지의 모든 스크립트가 그 권한을 가진다는 뜻이다. XSS·CDN 변조·외부 라이브러리 모두 RCE 통로가 됐다.

변화의 타임라인

버전변화이유
v0.37 (2016)contextIsolation 옵션 추가Chromium의 isolated world 패턴 차용
v5 (2019)nodeIntegration 디폴트 falseXSS → RCE 사고 다발
v12 (2021)contextIsolation 디폴트 truepreload·renderer 분리가 기본값이어야 한다
v14 (2021)remote 모듈 제거원격 객체 노출 = 너무 위험
v20 (2022)sandbox: true 적극 권장추가 OS 수준 격리
v28 (2024)exposeInIsolatedWorld 추가Chrome extension 시나리오

지금의 안전 디폴트 — 명시적으로 박아둘 것

new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,        // 명시
    contextIsolation: true,        // 명시
    sandbox: true,                 // 가능하면
    webSecurity: true,             // 명시
    preload: path.join(__dirname, 'preload.js'),
  },
});

이 설정이 모든 Electron 보안 가이드의 첫 줄이다.


What-if — contextIsolation: false로 돌아가면

가끔 기존 코드 호환 때문에 contextIsolation: false를 켜는 경우가 있다. 그 대가:

영향결과
window.api = ... 직접 할당 가능XSS가 그 객체 그대로 접근
Function.constructor 우회 가능임의 JS 코드 실행 → process 접근
Electron 보안 권고 위반보안 audit 실패
remote 모듈 (v14 이전)메인 객체 그대로 노출
외부 라이브러리 (jQuery·analytics)도 그 권한 상속공급망 공격 위험

기존 코드 호환이라는 핑계는 6개월 마이그레이션 계획으로 풀어야 할 빚이다. 영구히 끄지 말 것.


How — TypeScript 타입 공유

contextBridge로 노출된 객체에 타입을 어떻게 붙이나? 표준 패턴은 declare global이다 (다음 문서 04에서 깊이 다룸).

// preload.ts
import type { IpcRenderer } from 'electron';
import { contextBridge, ipcRenderer } from 'electron';
 
const api = {
  readFile: (path: string): Promise<string> => ipcRenderer.invoke('fs:read', path),
  getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version'),
} as const;
 
contextBridge.exposeInMainWorld('api', api);
 
// 타입 export
export type Api = typeof api;
// renderer/global.d.ts
import type { Api } from '../preload/preload';
 
declare global {
  interface Window {
    api: Api;
  }
}
 
export {};

이렇게 하면 — renderer에서 window.api.readFile('x')자동완성되고 컴파일 타임 타입 체크된다.


Insight — 한 단락 이야기

“contextBridge는 Chrome extension의 isolated world를 그대로 가져온 것”

2010년대 초 Chrome extension 팀은 비슷한 문제에 부딪쳤다 — extension의 content script웹 페이지같은 DOM을 공유하지만 다른 권한을 가져야 했다. 그 답이 isolated world — V8 context를 별도로 띄우고, DOM은 공유하되 JS 변수·prototype은 분리하는 모델이었다. Electron v0.37에서 처음 옵션으로 들어왔고, v12에 디폴트가 됐다. 즉 — Electron이 발명한 게 아니라 Chrome extension의 10년 노하우디폴트로 끌어올린 것이다. 흥미로운 반전 — 이 패턴이 너무 잘 작동해서, 2020년 이후 Chrome Manifest V3모든 extension에 사실상 강제하는 모델로 자리 잡았다. Electron이 빌린 패턴Chrome 본진의 표준이 된 셈이다. 같은 시기에 VS Code의 Extension Host도 이 모델을 프로세스 분리로 한 단계 더 밀고 갔고 — 결과적으로 모든 큰 데스크톱 JS 앱isolated context기본 위생으로 받아들였다.


요약 + Mermaid

핵심 키
게이트 함수contextBridge.exposeInMainWorld(key, obj)
통과 가능원시 · 객체 리터럴 · 배열 · 함수 · Promise · Date · RegExp · Map · Set
통과 불가require · ipcRenderer 통째 · DOM · EventEmitter · Symbol
함수 시그니처 원칙동사 단위로 좁게 — 채널 이름 은닉 · 입력 plain 타입
디폴트 보안 옵션contextIsolation: true + nodeIntegration: false + sandbox: true
TypeScript 패턴declare global { interface Window { api: Api } }

한 줄 결론무엇을 노출하느냐공격 표면의 크기다. 다음 문서(04)는 그 노출된 함수들이 Promise + 타입 위에서 어떻게 런타임 안전성까지 가져가는지를 본다.