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가 실제로 하는 일
- preload context의 객체를 깊은 복사해서 renderer context로 옮긴다.
- 함수는 프록시로 감싸 — 호출되면 preload context에서 실행되고 결과만 복사돼 renderer로 돌아온다.
- 객체 참조는 노출되지 않는다 —
ipcRenderer자체나require같은 살아 있는 객체는 통과 불가. - 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 | ❌ | 통과 불가 |
Error | ✅ | message만 |
| 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'),
},
});규칙:
- 함수 단위로 — 객체 통째가 아니라 한 동작 한 함수.
- 도메인별 그룹 —
window.api.user.get형태. - 채널 이름은 안에 숨겨라 — renderer는 함수만 본다.
- 입력은 간단한 타입만 — 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 디폴트 false | XSS → RCE 사고 다발 |
| v12 (2021) | contextIsolation 디폴트 true | preload·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 + 타입 위에서 어떻게 런타임 안전성까지 가져가는지를 본다.