04 — nodeIntegration & remote
한 줄 답:
nodeIntegration: false는 renderer의window에require·process·Buffer가 없게 만든다 — Node API의 문 자체를 닫는다.remote모듈은 그 반대 방향의 사고였다 — main 객체를 자동으로 renderer에 proxy해서 모든 main API가 renderer에 통째로 도달했다. v9에서 외부 패키지로 격리, v14에서 완전 제거. 원칙은 단순하다 — 근본적으로 노출하지 말 것.
웹에서는 ~인데 Electron에서는 ~
웹에서는
<script>안에require·process·fs같은 이름이 애초에 존재하지 않는다 — 브라우저 JS 환경의 기본 전역이 아니다. Electron에서는 Chromium의 renderer 안에 Node.js 런타임이 함께 살고 있어서 — 옛 디폴트nodeIntegration: true에서는 —window.require('fs')가 작동했다.nodeIntegration: false는 Node 런타임을 제거하는 게 아니라, renderer의 전역에 Node 객체를 노출하지 않는 옵션이다 (Node 런타임 자체는 preload에서 여전히 동작).
Why — 노출 최소화가 가장 강한 보안
다층 격리(contextIsolation, sandbox, CSP)가 공격 후의 방어라면, 노출 최소화는 공격 표면 자체를 줄이는 예방적 원칙이다. 없는 것은 뚫리지 않는다.
| 옵션 | 노출 줄이는 방향 |
|---|---|
nodeIntegration: false | renderer 전역에서 Node API 제거 |
enableRemoteModule: false (v14에서 완전 제거) | main 객체의 자동 proxy 제거 |
nodeIntegrationInWorker: false | Web Worker에서도 Node API 제거 |
nodeIntegrationInSubFrames: false | iframe에서 Node API 제거 |
preload 함수 화이트리스트 | preload 자체에서도 필요한 함수만 |
Electron Fuses | 빌드 타임에 옵션 자체를 영구 봉인 |
각각이 다른 입구에 대응한다. iframe(nodeIntegrationInSubFrames)이 가장 자주 잊히는데, 그게 정확히 Discord 2018 사고의 원인이었다.
How — 노출 옵션 5가지의 정확한 의미
1) nodeIntegration
true이면 renderer JS가 그대로 require('fs')를 부를 수 있다. v5(2019)부터 디폴트 false. 새 프로젝트에서는 명시적으로 적어둘 것 — 디폴트가 안전해도 옛 boilerplate에서 true로 적힌 채 복사돼 들어오는 경우가 흔하다.
2) nodeIntegrationInWorker
Web Worker(브라우저의 백그라운드 스레드)에서도 별도로 켜야 적용. 디폴트 false. 잊으면 Worker 안에서 require 가능해진다.
3) nodeIntegrationInSubFrames
<iframe> 안에서도 Node API. 디폴트 false. Discord 2018 패턴의 정확한 옵션 — 옛 시절엔 부모 창이 nodeIntegration이면 iframe도 자동 상속하는 시기가 있었고, 그 시기의 사고가 이 옵션의 존재 이유.
// 외부 임베드 페이지 (youtube embed 등)를 다루는 앱은
// 부모만 nodeIntegration 끄지 말고, 명시적으로
new BrowserWindow({
webPreferences: {
nodeIntegration: false,
nodeIntegrationInSubFrames: false, // ← 명시
nodeIntegrationInWorker: false, // ← 명시
contextIsolation: true,
sandbox: true,
},
});4) remote 모듈 (역사)
remote는 renderer에서 main의 객체를 마치 로컬처럼 부르는 API였다.
// 옛 코드 (Electron v9 이전)
const { remote } = require('electron');
const win = remote.getCurrentWindow();
const fs = remote.require('fs'); // ❗ main의 fs를 renderer가 사용
fs.writeFileSync('/etc/passwd', 'pwned'); // 실제로 동작했음이 한 줄이 renderer에서 main의 모든 API를 자동 proxy했다. 즉:
nodeIntegration: false여도 remote가 켜져 있으면 의미 없음.- XSS →
remote.require('child_process').exec(...)한 줄. - main API의 모든 함수가 동기 IPC로 노출 — 성능도 나쁘고 보안도 나빴다.
| Electron 버전 | remote 모듈 상태 |
|---|---|
| ~ v8 | 기본 제공, enableRemoteModule 디폴트 true |
| v9 | enableRemoteModule 디폴트 false (break-change) |
| v10 | @electron/remote 외부 패키지로 분리 |
| v14 (2021-08) | core에서 완전 제거 |
| v15+ | @electron/remote 패키지로만 사용 가능 (권장하지 않음) |
→ remote 모듈을 여전히 쓰는 코드가 있다면 마이그레이션 1순위. 대안은 ipcRenderer.invoke + ipcMain.handle (03-ipc-bridge).
5) Electron Fuses (v15+) — 빌드 타임 봉인
Electron Fuses는 binary에 일종의 비트 플래그를 박아서 런타임 옵션을 영구 비활성시킨다. 한 번 봉인하면 코드에서 다시 켤 수 없다.
// fuses.config.js
const { flipFuses, FuseV1Options, FuseVersion } = require('@electron/fuses');
await flipFuses(
'/path/to/Electron.app/Contents/MacOS/Electron',
{
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false, // ELECTRON_RUN_AS_NODE 차단
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, // NODE_OPTIONS 차단
[FuseV1Options.EnableNodeCliInspectArguments]: false, // --inspect 차단
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, // asar 무결성
[FuseV1Options.OnlyLoadAppFromAsar]: true, // asar만 로드
}
);| Fuse | 막는 것 |
|---|---|
RunAsNode 비활성 | ELECTRON_RUN_AS_NODE=1 환경변수로 Electron binary를 순수 Node로 실행 — 공격자가 서명된 Electron binary로 임의 Node 실행하는 우회 차단 |
EnableNodeOptionsEnvironmentVariable 비활성 | NODE_OPTIONS=--require ... 환경변수로 임의 모듈 로드 차단 |
EnableNodeCliInspectArguments 비활성 | --inspect로 디버거 attach 차단 |
EnableEmbeddedAsarIntegrityValidation 활성 | asar 파일이 변조됐는지 검증 |
OnlyLoadAppFromAsar 활성 | app.asar 외부 파일 로드 차단 |
LoadBrowserProcessSpecificV8Snapshot 활성 | V8 snapshot 위변조 차단 |
→ Fuses는 디폴트로 모두 안전하지 않음. 빌드 파이프라인에 반드시 끼워넣어야 한다. electron-builder의 electronFuses 옵션이나 별도 스크립트로 봉인.
What — 사양과 운영
권장 webPreferences (노출 최소화 완전판)
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
// ----- 격리 (앞 두 챕터) -----
contextIsolation: true,
sandbox: true,
// ----- 노출 최소화 -----
nodeIntegration: false,
nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: false,
enableRemoteModule: false, // 옛 옵션, 명시 권장 (v14 이전 호환)
// ----- CSP·webSecurity (다음 챕터) -----
webSecurity: true,
allowRunningInsecureContent: false,
// ----- preload -----
preload: path.join(__dirname, 'preload.js'),
},
});electron-builder + Fuses 봉인
// package.json
{
"build": {
"electronFuses": {
"runAsNode": false,
"enableCookieEncryption": true,
"enableNodeOptionsEnvironmentVariable": false,
"enableNodeCliInspectArguments": false,
"enableEmbeddedAsarIntegrityValidation": true,
"onlyLoadAppFromAsar": true
}
}
}빌드 후 검증:
npx @electron/fuses read --app /path/to/MyApp.app
# 출력에서 모든 Fuse가 의도한 상태인지 확인자동 점검 도구
| 도구 | 하는 일 |
|---|---|
electronegativity | 정적 분석. nodeIntegration, remote, webSecurity, CSP, preload 패턴 검사. 25+ 룰. |
securify (구식) | 옛 시절 도구, 지금은 electronegativity로 대체 |
Electron 공식 security warnings | DevTools 콘솔에 안전하지 않은 옵션 경고 자동 출력 (개발 모드) |
@electron/fuses CLI | Fuses 봉인 상태 검증 |
권장 CI 파이프라인:
# .github/workflows/electron-security.yml
- name: Electronegativity static scan
run: npx @doyensec/electronegativity -i ./ -o report.sarif
- name: Verify Electron Fuses
run: npx @electron/fuses read --app dist/MyApp.app | grep "RunAsNode.*disabled"마이그레이션 체크리스트 (옛 코드 → 안전한 코드)
| 옛 패턴 | 옮길 곳 | 어떻게 |
|---|---|---|
require('electron').remote.dialog.showOpenDialog() | main으로 | ipcRenderer.invoke('dialog:open') + ipcMain.handle |
require('electron').remote.getCurrentWindow() | preload + IPC | ipcRenderer.invoke('window:minimize') 등 단위로 분해 |
require('fs') in renderer | main으로 | 위와 동일 |
require('child_process').spawn in renderer | main으로 | 같은 패턴 |
| iframe에서 nodeIntegration | 제거 | nodeIntegrationInSubFrames: false 명시 |
| Web Worker에서 Node API | Utility Process로 | utilityProcess.fork() |
What-if — 노출이 남아 있으면
1) “옛 코드에 remote가 한 줄 남아 있어도 v14 이상이면 안전?”
→ 동작은 안 함이지만 코드 자체가 신호다. 그 한 줄이 있다는 건 다른 노출도 있을 가능성이 높다. 대응: electronegativity로 전수 스캔.
2) “nodeIntegration: false인데 왜 ELECTRON_RUN_AS_NODE가 위험?”
→ Electron binary 자체가 순수 Node로 실행될 수 있는 능력을 갖고 있다. 공격자가 우리 앱의 서명된 Electron.exe를 임의 JS와 함께 실행하면 — 우리 앱의 인증서로 서명된 Node가 임의 코드 실행. macOS Gatekeeper도 통과한다.
대응: Fuse RunAsNode: false로 binary 자체에서 봉인.
3) “iframe에 youtube 임베드 정도는 안전?”
→ 외부 origin의 JS가 현재 webPreferences를 상속한다. youtube의 광고 네트워크가 손상되면(2018년 유사 사고 다수), 그 광고 JS가 우리 앱 권한으로 실행.
대응: 모든 외부 iframe을 <webview> 또는 <iframe sandbox="...">로 격리 + nodeIntegrationInSubFrames: false.
4) “preload가 너무 두꺼워졌어”
→ preload에 함수가 100개 있으면 그 자체가 공격 표면. 각 함수가 진짜 필요한지 검토. 대응: preload 코드 라인은 작을수록 좋다. 도메인 단위로 별도 preload를 두고 창마다 다른 preload를 attach.
5) “Fuses는 production에서만 켜자”
→ 개발 모드에서 Fuse 없이 작동하던 패턴이 production에서 깨진다.
대응: 개발 빌드부터 Fuses 켜기. 단, EnableNodeCliInspectArguments는 디버거 attach가 필요한 개발에서는 켜둘 수 있음 — production만 봉인.
Insight — 흥미로운 이야기
”remote 모듈은 얼마나 위험한지 몰랐던 시절의 유산”
remote 모듈은 2014년 Electron 초창기에 편의 API로 추가됐다. 의도는 renderer가 main의 함수를 자연스럽게 부르도록. 하지만 어떤 main 함수를 부를 수 있는가에 제한이 없었다 — 모든 main API가 자동 proxy. 2017~2018년 보안 연구자들이 수십 개 RCE PoC를 같은 패턴으로 보고하면서 Electron 팀이 제거 결정. v9에서 디폴트 off, v10에서 외부 패키지로 분리, v14에서 완전 제거 — 5년에 걸친 단계적 deprecation.
→ 교훈: 편의 API가 보안의 가장 큰 적이다. 처음부터 제한된 채로 설계된 API(ipcRenderer.invoke)가 결과적으로 표준이 됐다.
”Electron Fuses의 발명 이유”
2020년경 보안 연구자들이 서명된 Electron binary를 다른 앱이 재사용하는 공격을 보고했다. 공격자가 내 앱의 서명을 그대로 유지한 채 내가 의도하지 않은 JS를 실행할 수 있었다 — ELECTRON_RUN_AS_NODE=1로 코드 변경 없이. Electron 팀의 답이 Fuses — binary에 비트 플래그를 박아 런타임 옵션을 영구 봉인. v15에서 도입, v16부터 공식 문서에 권장 설정으로 등재.
→ 교훈: 코드 옵션은 코드를 바꾸면 우회 가능. 바이너리 비트가 더 강력하다.
”VS Code의 preload는 몇 줄인가”
VS Code의 메인 BrowserWindow preload는 수십 줄 수준이다. 거의 모든 로직이 main 또는 별도의 utility process에 있다. 큰 IDE의 preload가 그렇게 작다는 점이 시사적 — Node 작업을 renderer 옆에 두는 것은 최후의 수단이라는 디자인 원칙이 일관된다.
→ 교훈: preload는 얇을수록 좋다. 두꺼우면 책임이 어디인지 헷갈리고 공격 표면도 커진다.
요약 + 다이어그램
노출 최소화는 예방적 원칙이다.
nodeIntegration: false(renderer 전역 Node 제거) +nodeIntegrationInSubFrames/Worker: false(iframe·Worker도 차단) +remote제거(자동 proxy 사고) + Fuses(빌드 타임 봉인)의 4단 계층이 노출의 모든 입구를 닫는다. 원칙: 없는 것은 뚫리지 않는다.
다음 문서:
05-csp-and-webrequest.md— XSS 자체를 막는 층. Content-Security-Policy 설정과session.webRequest로 헤더 강제.webSecurity: false금기.