Electron 보안 체크리스트 — contextBridge 제대로 쓰기

게시일: 2025년 7월 22일 · 14분 읽기

nodeIntegration: true로 보안 감사에 걸렸다

처음 Electron 앱을 만들 때, 나는 편의를 위해 `nodeIntegration: true`로 설정했다. 렌더러 프로세스에서 Node.js API를 직접 쓸 수 있어서 편했다. 하지만 1년 뒤 보안 감사에서 걸렸다. "이건 심각한 보안 문제"라고.

그 뒤로 Electron 보안을 제대로 배웠다.

기본 보안 설정

<?xml version="1.0"?>
// main.ts
import { app, BrowserWindow } from 'electron'

function createWindow() {
  const win = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false,        // 반드시 false
      contextIsolation: true,        // 반드시 true
      enableRemoteModule: false,     // 반드시 false
      sandbox: true,                 // 반드시 true
      preload: path.join(__dirname, 'preload.ts')
    }
  })

  win.loadFile('index.html')
}

app.on('ready', createWindow)

Preload 스크립트로 안전하게 API 노출

<?xml version="1.0"?>
// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

// 렌더러 프로세스에서 사용할 수 있는 API만 노출
contextBridge.exposeInMainWorld('electronAPI', {
  // IPC 통신 래퍼
  readFile: (filePath: string) =>
    ipcRenderer.invoke('file:read', filePath),

  writeFile: (filePath: string, content: string) =>
    ipcRenderer.invoke('file:write', { filePath, content }),

  getAppVersion: () =>
    ipcRenderer.invoke('app:get-version'),

  // 윈도우 제어
  closeWindow: () =>
    ipcRenderer.send('window:close'),

  // 절대 이렇게 하면 안 됨!
  // require: (module: string) => require(module)  // 위험!
})

// 타입 정의
declare global {
  interface Window {
    electronAPI: {
      readFile: (path: string) => Promise<string>
      writeFile: (path: string, content: string) => Promise<void>
      getAppVersion: () => Promise<string>
      closeWindow: () => void
    }
  }
}

메인 프로세스에서 IPC 핸들러

<?xml version="1.0"?>
// main.ts
import { app, BrowserWindow, ipcMain } from 'electron'
import * as fs from 'fs/promises'
import * as path from 'path'

// 파일 읽기 (권한 확인)
ipcMain.handle('file:read', async (event, filePath: string) => {
  // 보안: 특정 디렉토리만 접근 허용
  const appDataPath = app.getPath('appData')
  const resolvedPath = path.resolve(filePath)

  if (!resolvedPath.startsWith(appDataPath)) {
    throw new Error('Access denied')
  }

  return await fs.readFile(resolvedPath, 'utf-8')
})

// 파일 쓰기 (검증)
ipcMain.handle('file:write', async (event, { filePath, content }) => {
  const appDataPath = app.getPath('appData')
  const resolvedPath = path.resolve(filePath)

  if (!resolvedPath.startsWith(appDataPath)) {
    throw new Error('Access denied')
  }

  // 파일 크기 제한 (DDoS 방지)
  if (content.length > 10 * 1024 * 1024) {  // 10MB
    throw new Error('File too large')
  }

  await fs.writeFile(resolvedPath, content, 'utf-8')
})

ipcMain.handle('app:get-version', async () => {
  return app.getVersion()
})

ipcMain.on('window:close', (event) => {
  BrowserWindow.fromWebContents(event.sender)?.close()
})

렌더러 프로세스에서 사용

<?xml version="1.0"?>
// renderer.ts (또는 React 컴포넌트)
async function loadUserData() {
  try {
    const data = await window.electronAPI.readFile('user-data.json')
    const config = JSON.parse(data)
    console.log(config)
  } catch (error) {
    console.error('Failed to load data:', error)
  }
}

async function saveUserData(config: any) {
  try {
    await window.electronAPI.writeFile(
      'user-data.json',
      JSON.stringify(config, null, 2)
    )
  } catch (error) {
    console.error('Failed to save data:', error)
  }
}

// React에서 사용
function Settings() {
  const [config, setConfig] = useState(null)

  useEffect(() => {
    loadUserData().then(setConfig)
  }, [])

  return (
    <div>
      {config && <p>Version: {window.electronAPI.getAppVersion()}</p>}
    </div>
  )
}

CSP (Content Security Policy)**

<?xml version="1.0"?>
// index.html에 CSP 설정
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <!-- 중요: 강력한 CSP 설정 -->
  <meta
    http-equiv="Content-Security-Policy"
    content="
      default-src 'self';
      script-src 'self' 'unsafe-inline';
      style-src 'self' 'unsafe-inline';
      img-src 'self' data:;
      font-src 'self';
    "
  />
  <title>My App</title>
</head>
<body>
  <div id="root"></div>
  <script src="index.js"></script>
</body>
</html>

또는 코드에서:

<?xml version="1.0"?>
// main.ts
function createWindow() {
  const win = new BrowserWindow({...})

  win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': [
          "default-src 'self'; " +
          "script-src 'self'; " +
          "style-src 'self' 'unsafe-inline';"
        ]
      }
    })
  })
}

업데이트 보안

<?xml version="1.0"?>
// 자동 업데이트는 HTTPS만 사용
import { autoUpdater } from 'electron-updater'

autoUpdater.checkForUpdatesAndNotify()

// 서명 확인
autoUpdater.on('before-download', () => {
  // 먼저 다운로드 URL 검증
  const url = autoUpdater.currentVersion.releaseUrl
  if (!url.startsWith('https://')) {
    throw new Error('Updates must use HTTPS')
  }
})

보안 체크리스트

마무리

Electron의 보안은 기본이다. 편의를 위해 nodeIntegration을 켜는 건 절대 금지다. contextBridge와 IPC는 조금 번거롭지만, 그만큼의 보안을 제공한다.

처음부터 제대로 하면, 나중에 보안 감사에서 헤맬 일이 없다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.