02 — Context Isolation
한 줄 답:
contextIsolation: true는 같은 V8 isolate 안에서 *renderer가 보는window*와 *preload가 보는window*를 *서로 다른 “world” (context)*로 분리한다. 둘은 DOM은 공유하지만 JS 전역과 prototype 체인은 분리된다. 그래서 renderer가Object.prototype을 오염시켜도 preload는 그 오염을 보지 않는다.
웹에서는 ~인데 Electron에서는 ~
웹에서는 같은 페이지의 모든
<script>가 *하나의window*를 공유한다 — 외부 스크립트와 내 스크립트가 같은 전역을 본다. CSP·SRI로 외부 스크립트 자체를 차단하는 것이 방어 모델이다. Electron에서는 renderer의 페이지 JS와 preload의 Node-aware JS가 같은 페이지 안에서 함께 실행된다. 둘이 같은window를 공유하면 — *preload가window.fs = require('fs')*를 노출한 순간 — renderer XSS가 fs에 닿는다.contextIsolation은 Chrome 확장이 content script를 격리하는 방식을 빌려와서 이 문제를 푼다.
Why — 왜 같은 V8 isolate에 두 world인가
가장 단순한 해법은 preload와 renderer를 다른 프로세스로 두는 것이다. 하지만 그러면 preload가 DOM을 만질 수 없다 — 핵심 기능 하나(예: 외부 사이트에 뱃지 주입)가 막힌다. 그래서 Chromium은 content script용으로 이미 발명해 두었던 world isolation을 Electron이 재활용한다.
| 격리 방식 | 장점 | 단점 |
|---|---|---|
| 다른 isolate (다른 프로세스) | 완전 격리 | DOM 공유 불가, IPC 필요 |
| 다른 isolate (같은 프로세스) | 격리 강함 | V8 GC가 비효율, DOM 객체 마샬링 필요 |
| 같은 isolate, 다른 context (world) | DOM은 공유, JS 전역은 분리 | V8 내부 API 사용 — Chromium만 가능 |
| 같은 context (옛 디폴트) | 빠름·간단 | preload 누설 시 끝 |
Electron이 선택한 것이 세 번째 — DOM은 공유하되 JS 전역(Object·Array·window 등의 prototype)은 분리한다. 결과는 Chrome 확장의 content script와 페이지 JS가 격리되는 방식과 동일.
How — V8 World가 어떻게 격리하나
V8 World 개념도
핵심:
- 두 world는 같은 V8 isolate에 있다 (같은 메모리 풀, 같은 GC).
- 두 world는 DOM은 공유한다 — preload가
document.getElementById를 호출하면 renderer가 만든 노드를 볼 수 있다. - 두 world는 JS 전역과 prototype이 완전히 다르다 —
Array·Object·window가 둘로 따로 존재한다.
코드로 보는 isolation
// preload.js
window.injected = "from preload"; // ① main world에 보이지 않음
window.__proto__.poisoned = "x"; // ② main world의 window.__proto__에 영향 없음
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('api', {
hello: () => "hi"
});// renderer (page) JS
console.log(window.injected); // undefined — main world에는 없음
console.log(window.__proto__.poisoned); // undefined
console.log(window.api.hello()); // "hi" — contextBridge로 노출된 것만 보임contextIsolation: false이면 두 줄 모두 제대로 동작해서 공격 통로가 된다.
Prototype Pollution 차단의 메커니즘
오래된 typescript-codebase에서 흔한 패턴.
// 어떤 옵션 병합 함수
function merge(defaults, opts) {
return Object.assign({}, defaults, opts);
}공격자가 renderer 안에서 Object.prototype.isAdmin = true를 심는다. 그 뒤 어떤 함수가 merge({}, userInput)를 호출하면 isAdmin: true가 전파된다 — 그 함수가 preload에 있어도 같은 prototype 체인이면 본다.
contextIsolation: true인 경우:
- renderer의
Object.prototype과 preload의Object.prototype은 다른 객체다. - renderer가
Object.prototype.isAdmin = true를 심어도 preload의Object는 영향 없음.
→ 이것이 Signal 2018 (CVE-2018-10994) 패턴의 근본 차단.
contextBridge — 좁은 통로
contextIsolation: true에서 renderer가 preload 객체에 접근하는 유일한 합법 경로는 contextBridge.exposeInMainWorld다. 그리고 contextBridge는 값 복사 또는 함수 프록시만 허용한다 — Node 객체를 그대로 노출할 수 없다.
// ❌ 작동하지 않음 (보안 차원에서 차단)
contextBridge.exposeInMainWorld('fs', require('fs'));
// ✅ 함수만 노출
contextBridge.exposeInMainWorld('api', {
readConfig: () => fs.readFileSync('/etc/app.conf', 'utf8'),
});→ Node 객체 직접 노출이 원천 차단되는 것이 contextBridge의 디자인.
What — 사양과 권장 설정
권장 webPreferences (그대로 복사 가능)
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
// ----- 보안 핵심 4종 -----
contextIsolation: true, // v12+ 디폴트 true, 명시 권장
nodeIntegration: false, // 디폴트 false, 명시 권장
sandbox: true, // v20+ 디폴트 true, 명시 권장
webSecurity: true, // 디폴트 true, 절대 false로 두지 말 것
// ----- 보조 -----
allowRunningInsecureContent: false,
experimentalFeatures: false,
enableBlinkFeatures: '',
// ----- preload 경로 -----
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
});contextBridge 사용 패턴 (안전한 IPC 브릿지)
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
// ✅ 좁은 함수만, 화이트리스트된 채널만
contextBridge.exposeInMainWorld('api', {
// 인자도 직접 검증
readConfig: () => ipcRenderer.invoke('config:read'),
saveConfig: (data) => {
if (typeof data !== 'object' || data === null) throw new Error('invalid');
return ipcRenderer.invoke('config:save', data);
},
// ❌ 이렇게 하면 안 됨 — 모든 채널 통과
// send: (channel, data) => ipcRenderer.send(channel, data),
// invoke: (channel, data) => ipcRenderer.invoke(channel, data),
});디폴트 변천사
| Electron 버전 | contextIsolation 디폴트 |
|---|---|
| ~ v11 | false |
| v12 (2021-03) | true (break-change) |
| v15+ | true 유지 + Fuse로 봉인 가능 |
| v28+ | true 유지, 비활성화 시 deprecation warning 강화 |
어떤 노출이 안전하고 어떤 게 위험한가
| 노출 패턴 | 안전한가? | 이유 |
|---|---|---|
exposeInMainWorld('api', { ping: () => 'pong' }) | ✅ | 인자 없는 함수, 부수효과 없음 |
exposeInMainWorld('api', { read: () => fs.readFileSync(CONST_PATH) }) | ✅ | 경로가 하드코딩 |
exposeInMainWorld('api', { read: (p) => fs.readFileSync(p) }) | ⚠️ | renderer가 임의 경로 읽기 가능 |
exposeInMainWorld('api', { invoke: (ch, d) => ipcRenderer.invoke(ch, d) }) | ❌ | 모든 IPC 채널 우회 |
exposeInMainWorld('fs', require('fs')) | ❌❌ | Node 객체 직접 — contextBridge가 거절하지만 우회 시도 시 매우 위험 |
window.api = { ... } (contextBridge 안 씀) | ❌❌ | contextIsolation 무력화 |
What-if — contextIsolation: false로 두면
1) preload의 전역 변수가 renderer에 새어 들어간다
// preload.js
const secretToken = "AKIA..."; // 의도: preload만 보기
window.helper = { foo: 1 };contextIsolation: false이면 renderer가 window.helper.constructor.constructor("return secretToken")()로 클로저 변수까지 추출 가능 (V8 함수 객체 재구성). true이면 world가 다르므로 closure 자체에 접근 불가.
2) Prototype pollution이 preload에 전염
이미 위에서 다룬 시나리오. Signal 2018의 근본 원인.
3) DOM 이벤트 핸들러 hijack
// preload.js
document.addEventListener('keydown', handler); // 의도: shortcut 가로채기false이면 renderer가 handler의 함수 객체에 접근해서 그 안의 변수를 조작할 수 있다. true이면 함수 객체가 다른 world에 격리돼서 함수 참조만 호출 가능, 내부 변수 접근 불가.
4) “그냥 보안 옵션 한 줄이니 켜자”라고만 알고 있으면
→ contextIsolation을 켜는 순간 기존 preload 코드가 깨진다. window.foo = bar가 main world에 안 보이기 때문에 어떤 기능이 안 됨 같은 증상이 나오고, 개발자가 임시방편으로 다시 끄는 경우가 많다.
대응: 켜면서 동시에 contextBridge로 명시 노출. 마이그레이션은 한 번에 끝내야 한다.
5) “renderer가 신뢰할 수 있는 우리 코드니까 괜찮다”
→ 우리 코드에 들어간 의존성은 항상 신뢰할 수 없다. React 19 빌드 결과물도 수백 개 npm 패키지를 거친다. 그 중 한 줄이 prototype을 오염시키면 끝. 대응: 신뢰 영역과 무관하게 항상 켜둔다.
Insight — 흥미로운 이야기
”Chrome 확장의 content script가 답이었다”
Electron 팀이 contextIsolation을 도입할 때 처음부터 발명한 게 아니다. Chrome 확장은 content script (확장이 페이지에 주입하는 JS)가 페이지 JS와 격리돼야 했고, 그래서 V8에 “isolated world” 기능을 이미 만들어 뒀다. Electron은 그 기능을 그대로 preload에 재사용한다.
→ 교훈: 보안 격리는 발명보다 재사용이 더 안전하다. 검증된 메커니즘을 그대로 가져온 것.
”v12 break-change가 일으킨 마이그레이션 폭풍”
2021년 3월 Electron v12에서 contextIsolation: true가 디폴트가 됐다. 그 직후 수많은 앱이 깨졌다 — preload가 window.foo로 노출하던 패턴이 전부 안 보이게 됐기 때문. Electron 팀은 마이그레이션 가이드를 3개월간 매주 업데이트했고, 결과적으로 Electron 생태계 전체가 contextBridge 패턴으로 전환됐다. 이 break-change가 없었다면 지금도 대부분의 앱이 옛 패턴이었을 것.
→ 교훈: 디폴트 변경이 가장 강력한 보안 마이그레이션 도구다. 옵션을 켜라고 권고하는 것보다 디폴트를 바꿔서 강제하는 게 효과적이다.
”process.contextIsolated 한 줄로 확인하기”
런타임에 contextIsolation이 켜져 있는지 확인하는 가장 짧은 방법.
// preload.js
console.log(process.contextIsolated); // true이면 켜짐이 한 줄을 모든 preload 파일 첫 줄에 두고, false면 throw하는 패턴이 사실상 표준 안전망.
요약 + 다이어그램
contextIsolation: true는 renderer JS와 preload JS를 같은 V8 isolate 안의 다른 world에 둔다. DOM은 공유되지만 JS 전역과 prototype은 완전히 분리된다 — Chrome 확장 content script와 같은 메커니즘. 유일한 합법 통로는contextBridge.exposeInMainWorld이며, 함수와 값만 노출 가능 — Node 객체 그대로는 거절된다.
다음 문서:
03-sandbox.md— 두 번째 격리층. Chromium sandbox로 Node API 자체를 renderer 프로세스에서 제거한다. v20+ 디폴트true.