03 — Sandbox
한 줄 답:
sandbox: true는 Chromium의 OS-level sandbox(macOS seatbelt, Linux seccomp-bpf, Windows job object/integrity level)를 켜서 renderer 프로세스를 OS가 직접 봉인한다. 그 결과 — renderer JS에서 Node API(require,process.binding,fs등)가 런타임에 제거되고, preload도 sandboxed preload라는 제한된 모드로만 실행된다. v20부터 디폴트true.
웹에서는 ~인데 Electron에서는 ~
웹에서는 Chromium sandbox가 디폴트로 항상 켜져 있다 — 브라우저의 renderer 탭은 어떤 origin이든 sandbox 안이다. 개발자가 끄거나 켤 일이 없다. Electron에서는 sandbox가 옵션이고, *v20 이전엔 디폴트가
false*였다.nodeIntegration: false만 켠 채 sandbox를 끈 상태가 오랫동안 권장 설정이었고, 그 결과 preload에 전체 Node 런타임이 살아 있었다. v20부터 디폴트가true가 되며 브라우저 수준의 sandbox가 비로소 디폴트.
Why — contextIsolation만으로 부족한 이유
contextIsolation: true는 JS 레벨의 격리다 — renderer JS가 preload 객체에 못 닿게 한다. 하지만 renderer 프로세스 자체가 Node API를 실행할 능력은 여전히 있다.
| 옵션 | 막는 것 | 못 막는 것 |
|---|---|---|
contextIsolation: true | renderer JS → preload 객체 누설 | renderer 프로세스의 OS syscall, 메모리 |
nodeIntegration: false | renderer JS에 require 노출 | preload는 여전히 모든 Node API |
sandbox: true | renderer 프로세스의 OS syscall, preload의 Node 대부분 | (이게 가장 강한 격리) |
가장 중요한 차이는 공격자가 preload 자체에 어떤 식으로든 진입했을 때. contextIsolation은 진입을 막지만, sandbox는 진입 후에도 할 수 있는 일을 OS 차원에서 제한한다. 즉 깊이 방향의 마지막 보루가 sandbox.
또 하나 — V8 자체의 zero-day가 발견되면 contextIsolation은 우회된다 (V8 메모리 corruption). sandbox는 V8 밖의 OS에서 막아주므로 V8 zero-day에도 프로세스 격리가 살아 있다.
How — OS sandbox가 어떻게 막나
Chromium sandbox 구조
핵심 메커니즘:
- *renderer 프로세스의 OS syscall이 *대부분 차단**됨 (Linux는 약 100개 syscall만 화이트리스트).
- 파일·네트워크·자식 프로세스는 Main process(broker)에게 IPC로 요청해야 가능.
- V8 메모리 corruption이 나도 sandbox가 OS 자원 접근을 막아준다.
sandboxed preload — 무엇이 되고 무엇이 안 되나
sandbox: true인 상태에서 preload는 완전 Node 런타임을 잃는다. 대신 제한된 일부만 살아 있다.
| 분류 | sandbox 끔 | sandbox 켬 |
|---|---|---|
require('electron') | ✅ 전체 모듈 | ⚠️ 일부 모듈만 (ipcRenderer, contextBridge, webFrame, crashReporter) |
require('fs') | ✅ | ❌ |
require('child_process') | ✅ | ❌ |
require('path') | ✅ | ❌ (renderer에 없음) |
process.platform | ✅ | ✅ (제한된 process 객체) |
process.versions | ✅ | ✅ |
process.env | ✅ | ❌ (대부분 차단) |
Buffer | ✅ | ❌ |
setImmediate | ✅ | ❌ |
contextBridge.exposeInMainWorld | ✅ | ✅ |
ipcRenderer.invoke/send | ✅ | ✅ |
→ 즉 sandbox: true의 preload는 *“IPC 다리”*로서만 작동한다 — Node 작업은 반드시 main process에서. 이것이 바로 제대로 된 Electron 구조다.
코드 패턴 비교
// ❌ sandbox 끈 시절 패턴 — preload에서 직접 fs 호출
const fs = require('fs');
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('api', {
readConfig: () => fs.readFileSync('/etc/app.conf', 'utf8'),
});// ✅ sandbox 켠 패턴 — preload는 IPC 다리, fs는 main에서
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
readConfig: () => ipcRenderer.invoke('config:read'),
});// main.js
const { ipcMain } = require('electron');
const fs = require('fs/promises');
ipcMain.handle('config:read', async () => {
// 여기서 인가·검증·로깅 — main만 fs 접근 가능
return fs.readFile('/etc/app.conf', 'utf8');
});이 구조의 부수 효과: 모든 fs 접근에 main에서 검증을 끼울 수 있다. preload가 직접 fs를 잡고 있으면 검증을 깜빡한 곳이 생긴다.
Sandbox 디폴트 변천사
| Electron 버전 | sandbox 디폴트 |
|---|---|
| ~ v19 | false |
| v20 (2022-08) | true (break-change — Chromium 105 정렬) |
| v22+ | true 유지, Fuse로 봉인 가능 |
v20 break-change는 contextIsolation v12와 함께 Electron 보안 디폴트의 두 번째 큰 분수령. 이 두 디폴트 이전 시절에 만들어진 boilerplate·튜토리얼은 거의 다 안전하지 않다.
What — 사양과 운영
권장 webPreferences (sandbox 명시)
const win = new BrowserWindow({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true, // ← 명시
webSecurity: true,
preload: path.join(__dirname, 'preload.js'),
},
});sandbox 켠 preload 검사 (보안 자기진단)
// preload.js — 첫 줄에
console.assert(process.sandboxed === true, 'sandbox not active');
console.assert(process.contextIsolated === true, 'contextIsolation off');
// preload가 fs에 닿으면 안 되는지 확인 (개발 모드)
try {
require('fs');
throw new Error('preload should NOT have fs in sandbox mode');
} catch (e) {
// ERR_REQUIRE_ESM 같은 다른 에러가 아닌지 검증 필요
}sandbox와 native module의 관계
sandbox 켠 renderer는 native module을 로드할 수 없다 (.node 파일). 그래서 네이티브 의존성이 있는 코드는 반드시 main 또는 utility process로 옮겨야 한다.
// ❌ sandbox renderer에서는 작동하지 않음
const sqlite3 = require('better-sqlite3'); // native binding
// ✅ main에서 로드 후 IPC로 전달
// main.js
const sqlite3 = require('better-sqlite3');
const db = sqlite3('app.db');
ipcMain.handle('db:query', (e, sql) => db.prepare(sql).all());Utility process — sandbox renderer의 옆 보조
Electron 22+에서 추가된 UtilityProcess는 sandbox renderer가 못 하는 Node 작업을 위한 별도 프로세스. main 부담 없이 별도 격리된 Node 환경에서 무거운 작업을 돌릴 수 있다.
// main.js
const { utilityProcess } = require('electron');
const proc = utilityProcess.fork(path.join(__dirname, 'heavy.js'));
proc.postMessage({ task: 'parse', payload: '...' });이 패턴이 sandbox 강제 + 무거운 Node 작업 둘 다 만족하는 사실상 표준.
디버깅: 왜 sandbox가 안 켜질까
| 증상 | 원인 |
|---|---|
sandbox: true 설정했는데 process.sandboxed === false | nodeIntegration: true가 동시에 켜져 있음 (둘이 양립 불가, nodeIntegration이 이긴다) |
| Linux에서 sandbox 실패 | --no-sandbox flag가 들어가 있거나, user namespace 미지원 커널 |
| docker에서 항상 실패 | docker run에 --cap-add=SYS_ADMIN 또는 --security-opt seccomp=unconfined 필요 |
preload에서 Buffer 못 씀 | sandbox 켜진 정상 동작 — ipcRenderer.invoke로 main에 위임 |
What-if — sandbox를 끄면
1) “옛 코드가 깨지니까 다시 끄자”
→ 정확히 공격자가 노리는 경로가 다시 열린다. 옛 코드의 Node 의존성을 main으로 옮기고 sandbox는 켠 채로 둔다.
대응: utilityProcess로 옮기거나, IPC handler로 main에 위임.
2) “production 빌드에서만 켜자”
→ 개발 모드에서 안 켜져 있으면 개발자가 모르고 Node API에 의존하게 된다. production에서 깨진다. 대응: 개발과 production 동일하게 켜둔다. Fuse로 강제 봉인.
3) “sandbox 켰는데도 preload가 무거워서 시작이 느림”
→ sandbox preload는 별도의 V8 컨텍스트 + 제한된 require를 만드는 비용이 있다. 그래도 Chromium 디폴트 동작과 같으므로 브라우저 탭과 동일한 비용. 대응: preload를 얇게 유지 — IPC 다리만, 로직은 main으로.
4) “renderer에서 직접 fetch도 막혀야 안전?”
→ sandbox는 fetch는 막지 않는다 — fetch는 Chromium의 네트워크 스택을 거치고, broker(main process)가 정책을 적용한다. CSP·CORS·webRequest가 그 정책. (05)
5) “다 켰는데도 CVE가 나옴”
→ sandbox 자체에 escape 취약점이 종종 나온다 (Chromium 본진 영향). 그래서 Electron 버전 업데이트가 핵심 보안 책임 — Chromium 보안 패치를 따라가야 한다. 6개월 이상 묵은 Electron은 알려진 sandbox escape에 노출돼 있다.
Insight — 흥미로운 이야기
”Electron 팀의 가장 느렸던 break-change”
sandbox: true를 디폴트로 만드는 건 Electron 팀이 가장 오래 끌었던 결정이다. 2017년부터 논의가 있었고, 2022년 v20에서야 디폴트가 됐다. 이유는 생태계 호환성 — 너무 많은 앱이 preload에서 fs 직접 호출하는 패턴을 쓰고 있어서, 디폴트를 바꾸면 대규모 호환성 사고가 예상됐다. v22까지의 마이그레이션 가이드는 *“sandbox로 가는 길”*이라는 제목으로 3년치 글이 이어졌다.
→ 교훈: 보안 디폴트 변경은 호환성과의 트레이드오프. 안전한 길로 가는 데 시간이 걸린다.
”Chromium sandbox에 zero-day가 났을 때”
2022년 CVE-2022-3075는 Chromium 메인 sandbox escape였다. 모든 Electron 앱이 영향받았고, 수십 개 앱이 같은 주에 Electron 버전 업데이트만으로 패치를 배포했다. 그 사건이 auto-update가 보안 인프라라는 점을 명백히 했다 — Electron 버전이 sandbox의 강도다.
→ 교훈: sandbox는 옵션이지만 한 번 켜면 자동 따라오는 의존성이다. Electron 자체의 업데이트 책임을 진다는 의미.
”Slack의 sandbox: true 전환 이야기”
Slack 데스크톱은 2020년경 sandbox: true로 전환하며 수개월 마이그레이션을 거쳤다. 그 과정 블로그에서 — *“preload 코드 라인이 80% 줄었다”*고 보고했다. preload에서 하던 일을 다 main으로 옮긴 결과. 결과적으로 코드 품질도 좋아졌다 — preload가 얇아져 책임이 명확해졌다.
→ 교훈: sandbox 마이그레이션은 보안 개선만이 아니라 아키텍처 정리의 부산물을 준다.
요약 + 다이어그램
sandbox: true는 OS 차원의 봉인이다. macOS seatbelt / Linux seccomp-bpf / Windows job object가 renderer 프로세스의 syscall을 제한하고, 그 결과 *preload도 sandboxed mode가 되어 Node API 대부분이 사라진다. contextIsolation이 JS 격리라면 sandbox는 프로세스 격리. 둘이 함께 켜져야 defense in depth가 완성된다. v20+ 디폴트.
다음 문서:
04-node-integration-and-remote.md— 왜 nodeIntegration이 false가 디폴트인가, 그리고 remote 모듈이 v14에서 제거된 이유. 노출 자체를 줄이는 원칙.