⚡ Electron5. 보안 모델04 — nodeIntegration & remote

04 — nodeIntegration & remote

한 줄 답: nodeIntegration: falserenderer의 windowrequire·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: falseNode 런타임을 제거하는 게 아니라, renderer의 전역에 Node 객체를 노출하지 않는 옵션이다 (Node 런타임 자체는 preload에서 여전히 동작).


Why — 노출 최소화가 가장 강한 보안

다층 격리(contextIsolation, sandbox, CSP)가 공격 후의 방어라면, 노출 최소화공격 표면 자체를 줄이는 예방적 원칙이다. 없는 것은 뚫리지 않는다.

옵션노출 줄이는 방향
nodeIntegration: falserenderer 전역에서 Node API 제거
enableRemoteModule: false (v14에서 완전 제거)main 객체의 자동 proxy 제거
nodeIntegrationInWorker: falseWeb Worker에서도 Node API 제거
nodeIntegrationInSubFrames: falseiframe에서 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 모듈 (역사)

remoterenderer에서 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
v9enableRemoteModule 디폴트 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-builderelectronFuses 옵션이나 별도 스크립트로 봉인.


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 warningsDevTools 콘솔에 안전하지 않은 옵션 경고 자동 출력 (개발 모드)
@electron/fuses CLIFuses 봉인 상태 검증

권장 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 + IPCipcRenderer.invoke('window:minimize') 등 단위로 분해
require('fs') in renderermain으로위와 동일
require('child_process').spawn in renderermain으로같은 패턴
iframe에서 nodeIntegration제거nodeIntegrationInSubFrames: false 명시
Web Worker에서 Node APIUtility 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: falsebinary 자체에서 봉인.

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 팀의 답이 Fusesbinary에 비트 플래그를 박아 런타임 옵션을 영구 봉인. 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.mdXSS 자체를 막는 층. Content-Security-Policy 설정과 session.webRequest로 헤더 강제. webSecurity: false 금기.