Sik.limited Logo

Electron 보안 설계: preload·contextBridge·IPC를 좁게 만드는 법

Electron 앱에서 renderer와 OS 권한 사이의 경계를 설계하는 방법을 정리했습니다. preload·contextBridge·IPC를 좁은 계약으로 만들고, 파일·외부 링크·원격 콘텐츠를 안전하게 다루는 코드와 점검표를 제공합니다.

Sik ·

Electron을 처음 붙일 때는 화면이 빨리 나옵니다. 웹에서 쓰던 React·Svelte·Vue와 Node.js 패키지를 그대로 가져오면 되기 때문이에요. 그런데 앱이 파일을 열고, OS 알림을 띄우고, 외부 링크를 열고, 자동 업데이트를 받기 시작하면 문제의 이름이 바뀝니다. “renderer에서 무엇을 할 수 있게 해 줄까”가 아니라 “손상되거나 예상 밖의 renderer가 어디까지 할 수 있게 둘까”가 됩니다.

이 글의 결론부터 말하면, Electron 보안은 설정 한 줄의 문제가 아니에요. preload은 권한을 전달하는 관문이고, contextBridge는 공개 API의 계약이며, IPC는 서버 API처럼 설계해야 합니다. 이 셋을 좁게 만들면 기능을 덜 주는 것이 아니라, 기능이 늘어도 검토할 수 있는 앱을 만들게 됩니다.

기존에 데스크톱 프레임워크의 선택 자체가 고민이라면 Tauri vs Electron: SvelteKit 데스크톱 앱 선택 기준도 먼저 읽어볼 만합니다. 여기서는 Electron을 선택했다는 전제에서, 그 선택을 안전하게 운영하는 방법만 다룹니다.

보안 경계는 창이 아니라 권한의 흐름이다

Electron 앱에는 보통 세 종류의 코드가 있습니다.

위치맡는 일기본 태도
main process창, 파일 시스템, OS API, IPC 핸들러권한을 실행하는 곳
preloadrenderer와 main 사이의 제한된 연결기능을 번역하는 곳
renderer화면, 사용자 입력, 웹 콘텐츠신뢰하지 않고 다루는 곳

가장 흔한 오해는 로컬 번들을 loadFile()로 열기 때문에 renderer를 믿어도 된다는 생각이에요. 그러나 앱 안에는 마크다운 미리보기, 붙여 넣은 HTML, 외부 OAuth 창, 링크 미리보기, 플러그인 UI처럼 예상보다 빨리 외부 입력이 들어옵니다. XSS 하나가 생겼을 때 renderer가 require('fs')를 쓸 수 있거나 IPC 이름을 마음대로 보낼 수 있다면, 화면 버그가 데스크톱 권한 문제로 커집니다.

그래서 보안 모델은 이렇게 읽는 편이 맞습니다.

사용자 입력·웹 콘텐츠
          ↓
      renderer
          ↓  (의도와 데이터가 제한된 API)
       preload
          ↓  (채널·스키마·발신 창 검증)
         main
          ↓
  파일·키체인·OS·네트워크

여기서 preload는 mini backend가 아닙니다. 무거운 파일 처리나 권한 판단을 넣어 두는 장소도 아니에요. renderer가 호출할 수 있는 작은 함수 목록을 선언하는 어댑터에 가깝습니다. 실제 권한 판단은 main에서 한 번 더 해야 합니다.


창의 기본값을 코드에서 명시한다

최근 Electron은 context isolation과 renderer sandbox를 기본적으로 켜는 방향이지만, 기본값에만 기대지 않는 편이 좋습니다. 팀의 업그레이드, boilerplate 교체, 특수 창 추가에서 설정이 달라질 수 있기 때문입니다. 특히 새 창을 만드는 코드는 보안 리뷰에서 가장 먼저 찾기 쉬운 곳이기도 합니다.

// main/window.ts
import { BrowserWindow } from 'electron'
import path from 'node:path'

export function createMainWindow() {
  return new BrowserWindow({
    width: 1280,
    height: 840,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      sandbox: true,
      nodeIntegration: false,
      webSecurity: true,
      webviewTag: false,
      enableBlinkFeatures: '',
      allowRunningInsecureContent: false
    }
  })
}

이 설정은 “완전히 안전하다”는 인증서가 아닙니다. 다만 renderer에서 Node 런타임을 직접 쓰지 못하게 하고, preload와 페이지의 JavaScript 전역 객체를 분리하며, sandboxed renderer가 높은 권한을 얻으려면 IPC를 거치게 만듭니다. 중요한 점은 nodeIntegration: false만으로 끝나지 않는다는 것입니다. context isolation을 끄면 preload가 가진 강한 API가 페이지 쪽 전역 객체와 섞일 수 있고, sandbox의 이점도 약해집니다.

여러 종류의 창이 있는 앱은 기본 창 하나만 잘 잠가서는 부족합니다. 로그인 창, 도움말 창, PDF 미리보기, 에러 리포트 창처럼 “잠깐 뜨는 창”도 같은 정책을 가져야 해요. 신뢰 수준이 다른 콘텐츠를 띄워야 한다면 같은 BrowserWindow에 섞지 말고, 전용 preload와 전용 권한 목록을 가진 별도 창으로 분리하는 편이 낫습니다.

URL 이동과 새 창도 권한 요청으로 취급한다

외부 URL은 renderer가 알아서 새 창으로 열게 두기보다, main이 호스트를 확인한 뒤 시스템 브라우저로 넘기는 방식이 안전합니다. shell.openExternal()은 URL 문자열을 받는다고 가볍게 생각하기 쉽지만, 신뢰하지 않은 문자열을 넘기면 위험한 프로토콜이나 의도치 않은 목적지로 이어질 수 있습니다.

import { shell } from 'electron'

const ALLOWED_ORIGINS = new Set([
  'https://accounts.example.com',
  'https://docs.example.com'
])

function isAllowedExternal(urlString: string) {
  try {
    const url = new URL(urlString)
    return url.protocol === 'https:' && ALLOWED_ORIGINS.has(url.origin)
  } catch {
    return false
  }
}

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
  if (isAllowedExternal(url)) void shell.openExternal(url)
  return { action: 'deny' }
})

mainWindow.webContents.on('will-navigate', (event, url) => {
  if (url !== mainWindow.webContents.getURL()) {
    event.preventDefault()
    if (isAllowedExternal(url)) void shell.openExternal(url)
  }
})

허용 목록은 제품 정책이에요. 모든 https: URL을 바로 허용하는 것은 검증을 한 번도 하지 않는 것과 비슷합니다. OAuth처럼 동적으로 정해지는 경우도 redirect URI, host, path prefix를 명시적으로 좁힐 수 있는지 먼저 보세요. 이 코드는 보안 기능이면서 동시에 제품 동작을 문서화하는 역할을 합니다.

preload는 기능 목록이지 IPC 배관이 아니다

아래 코드는 편하지만 안전하지 않습니다.

// 하지 말 것
contextBridge.exposeInMainWorld('electron', {
  send: ipcRenderer.send,
  invoke: ipcRenderer.invoke,
  on: ipcRenderer.on
})

이렇게 노출하면 renderer의 어떤 코드든 임의 채널로 메시지를 보내거나, 원래 화면에 전달될 생각이 없던 이벤트를 구독할 수 있습니다. “우리 앱 renderer는 우리 코드뿐”이라는 전제가 깨지는 순간, IPC 전체가 renderer의 공개 API가 됩니다. Electron의 공식 가이드도 raw IPC 객체 대신 메시지마다 한 함수를 노출하라고 권합니다.

좋은 bridge는 UI가 실제로 필요로 하는 동사만 말합니다. 채널 이름을 UI에 노출하지 않고, 입력도 함수 경계에서 정규화합니다.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron'

type SaveTextInput = {
  suggestedName: string
  content: string
}

function asSaveTextInput(value: unknown): SaveTextInput {
  if (!value || typeof value !== 'object') throw new Error('잘못된 저장 요청입니다.')

  const input = value as Record<string, unknown>
  if (typeof input.suggestedName !== 'string' || typeof input.content !== 'string') {
    throw new Error('저장할 텍스트 형식이 아닙니다.')
  }

  return {
    suggestedName: input.suggestedName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80),
    content: input.content.slice(0, 1_000_000)
  }
}

const desktop = {
  chooseTextFile: () => ipcRenderer.invoke('file:choose-text'),
  saveText: (input: unknown) => ipcRenderer.invoke('file:save-text', asSaveTextInput(input)),
  getAppVersion: () => ipcRenderer.invoke('app:version'),
  openHelp: () => ipcRenderer.invoke('navigation:open-help')
}

contextBridge.exposeInMainWorld('desktop', desktop)

이 패턴은 조금 장황해 보입니다. 하지만 앱이 커질수록 이 장황함이 이득이 됩니다. desktop.saveText()를 검색하면 renderer가 요구한 제품 기능을 찾고, file:save-text를 검색하면 권한을 실행하는 곳을 찾을 수 있어요. 임의 문자열을 주고받는 하나의 범용 RPC보다, 도메인 함수 여러 개가 감사와 삭제에 훨씬 유리합니다.

TypeScript 타입도 같이 둬야 합니다. 타입은 런타임 검증을 대신하지 않지만, UI가 bridge의 숨은 기능을 추측해 호출하는 일을 줄여 줍니다.

// renderer-env.d.ts
export {}

declare global {
  interface Window {
    desktop: {
      chooseTextFile(): Promise<{ name: string; content: string } | null>
      saveText(input: { suggestedName: string; content: string }): Promise<void>
      getAppVersion(): Promise<string>
      openHelp(): Promise<void>
    }
  }
}

이벤트는 콜백이 아니라 구독 해제까지 계약한다

이벤트 API를 만들 때는 Electron의 event 객체를 renderer로 넘기지 않는 편이 좋습니다. 발신자 관련 객체를 불필요하게 전파할 이유가 없고, UI는 메시지 payload만 알면 됩니다. 구독 해제 함수를 반환하면 화면 전환 뒤 리스너가 쌓이는 문제도 막을 수 있습니다.

// preload.ts
const desktop = {
  onUpdateProgress(callback: (progress: number) => void) {
    const listener = (_event: Electron.IpcRendererEvent, progress: unknown) => {
      if (typeof progress === 'number' && Number.isFinite(progress)) {
        callback(Math.max(0, Math.min(progress, 100)))
      }
    }

    ipcRenderer.on('update:progress', listener)
    return () => ipcRenderer.removeListener('update:progress', listener)
  }
}
// renderer component lifecycle 예시
const unsubscribe = window.desktop.onUpdateProgress(setProgress)

// 컴포넌트가 사라질 때
unsubscribe()

앱 전체에 broadcast가 필요한 이벤트라면 누가 발행하고 누가 받는지 명확히 적으세요. 파일 저장 완료 같은 요청-응답은 invoke/handle이 더 읽기 쉽고, 업데이트 진행처럼 main이 먼저 말해야 하는 경우에만 이벤트를 선택하는 편이 좋습니다.


main의 IPC handler는 작은 서버 엔드포인트다

bridge에서 검증했으니 main은 믿어도 된다고 생각하면 안 됩니다. preload는 배포물의 일부이고, handler는 다른 창 또는 나중의 코드 변경에서도 호출될 수 있습니다. main handler는 HTTP API를 만들 때처럼 다음 네 가지를 확인해야 합니다.

  1. 누가 보냈는가
  2. 어떤 동작을 요청했는가
  3. 데이터의 모양과 범위가 맞는가
  4. 결과와 오류를 얼마나 공개할 것인가

먼저 sender를 확인합니다. 창이 하나뿐인 초기 앱에서는 번거롭게 느껴질 수 있지만, 설정 창·인증 창·미리보기 창이 늘면 가장 싼 방어선이 됩니다.

import { BrowserWindow, dialog, ipcMain } from 'electron'
import fs from 'node:fs/promises'

function assertMainWindow(event: Electron.IpcMainInvokeEvent) {
  if (event.sender.id !== mainWindow.webContents.id) {
    throw new Error('허용되지 않은 창의 요청입니다.')
  }
}

ipcMain.handle('file:save-text', async (event, input: unknown) => {
  assertMainWindow(event)

  if (!input || typeof input !== 'object') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const { suggestedName, content } = input as Record<string, unknown>
  if (typeof suggestedName !== 'string' || typeof content !== 'string') {
    throw new Error('저장 요청이 올바르지 않습니다.')
  }

  const result = await dialog.showSaveDialog(BrowserWindow.fromWebContents(event.sender)!, {
    defaultPath: `${suggestedName}.txt`,
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }]
  })

  if (result.canceled || !result.filePath) return { saved: false }
  await fs.writeFile(result.filePath, content, 'utf8')
  return { saved: true }
})

여기서 중요한 선택은 renderer가 절대 경로를 전달하지 않는다는 점입니다. “내 문서를 이 경로에 저장해”가 아니라 “이 내용을 저장하고 싶다”까지만 renderer가 말하고, OS 파일 대화상자와 최종 경로는 main이 맡습니다. 제품 요구가 실제로 특정 작업 폴더를 필요로 한다면, 허용된 루트에서 path.resolve()한 뒤 루트를 벗어나지 않는지 확인하세요.

import path from 'node:path'

function resolveInside(root: string, requested: string) {
  const absoluteRoot = path.resolve(root)
  const target = path.resolve(absoluteRoot, requested)

  if (target !== absoluteRoot && !target.startsWith(`${absoluteRoot}${path.sep}`)) {
    throw new Error('허용된 작업 폴더 밖의 경로입니다.')
  }

  return target
}

이 검증 없이 ../../를 포함한 문자열을 붙이는 실수는 파일 접근 기능에서 자주 나옵니다. 파일명, 확장자, 크기, 인코딩도 제품 요구에 맞게 좁히세요. “나중에 필요할 수 있으니 모두 허용”은 IPC에서 가장 비싼 기술 부채입니다.

채널 이름보다 명령 모델을 먼저 설계한다

채널을 대충 fs, system, data 같은 넓은 이름으로 만들면 그 안에 option 객체가 계속 자랍니다. 몇 달 뒤에는 renderer가 사실상 mini shell이 됩니다. 반대로 역할이 선명한 명령은 허용할 데이터를 선명하게 합니다.

넓고 위험한 API좁고 검토 가능한 API
ipc.invoke('fs:write', path, contents)desktop.saveExport({ format, content })
ipc.invoke('shell:open', url)desktop.openDocumentation()
ipc.invoke('process:exec', command)desktop.revealExportFolder()
ipc.send('config:set', key, value)settings.updateTheme(theme)

두 번째 열은 기능이 더 적어 보일 수 있습니다. 사실은 제품 언어로 권한을 정의한 것입니다. 예를 들어 revealExportFolder()는 OS 명령을 외부에 노출하지 않으면서도 사용자가 필요한 결과를 줍니다. 변경 리뷰에서 “왜 shell access가 필요한가?” 대신 “왜 export folder를 열어야 하는가?”를 논의하게 됩니다.

원격 콘텐츠를 띄우는 순간 정책이 달라진다

앱 번들만 여는 창과 외부 URL을 여는 창은 같은 보안 프로필을 공유하면 안 됩니다. 원격 도움말, 결제 페이지, OAuth, 사용자 생성 HTML, 광고나 문서 preview를 띄운다면 특히 그렇습니다.

  • 원격 콘텐츠 창에는 가능한 한 preload를 붙이지 않습니다.
  • 붙여야 한다면 외부 페이지 전용 bridge를 만들고, 앱의 파일·키체인·업데이트 기능은 넣지 않습니다.
  • 권한 요청은 세션 단위로 처리하고, 알림·미디어·카메라 같은 권한을 자동 승인하지 않습니다.
  • CSP를 설정하고, 페이지 탐색·새 창·다운로드 정책을 명시합니다.
  • 사용자 HTML은 앱의 renderer DOM에 innerHTML로 바로 주입하지 않습니다. sandboxed iframe, 안전한 Markdown 렌더러, 검증된 sanitizer 중 제품에 맞는 방식을 선택합니다.
import { session } from 'electron'

session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
  const origin = new URL(webContents.getURL()).origin
  const allowed = origin === 'https://accounts.example.com'
    && permission === 'notifications'

  callback(allowed)
})

이 예시는 알림만 특정 origin에 허용합니다. 실제 앱에서는 “왜 이 창에 이 권한이 필요한가”를 먼저 제품 요구로 적고, 그 요구가 없으면 handler도 만들지 않는 쪽이 더 단순합니다. 권한 정책은 기능표의 한 줄로 남겨야 나중에 비슷한 권한이 무심코 넓어지는 일을 막을 수 있어요.


보안 점검은 출시 직전이 아니라 기능 추가 시점에 한다

안전한 설정은 시간이 지나면 조금씩 무너집니다. 새 라이브러리가 webview를 요구하고, 급한 버그 수정이 contextIsolation: false를 제안하고, 임시 진단 명령이 범용 IPC가 되는 식입니다. 그래서 실무에서는 “Electron 보안 점검”을 별도 대형 프로젝트로 두기보다 PR 체크리스트에 넣는 편이 현실적입니다.

변경 상황꼭 물을 질문
새 BrowserWindow이 창은 로컬 번들만 여나, 외부 콘텐츠도 여나? 전용 preload가 필요한가?
새 IPCrenderer가 OS 권한을 직접 고르게 했나? 입력을 main에서도 검증하나?
새 파일 기능절대 경로·상위 디렉터리 이동·심볼릭 링크를 어떻게 다루나?
새 외부 링크정확히 어느 scheme·origin·path를 열어도 되는가?
새 플러그인/미리보기신뢰하지 않은 JS가 앱 bridge를 볼 수 있는가?
Electron 업그레이드보안 기본값·breaking change·패키지 취약점은 확인했나?

간단한 테스트도 효과가 큽니다. renderer에서 존재하지 않는 window.desktop 메서드를 호출했을 때 당연히 실패하는지, 허용되지 않은 창에서 IPC를 호출하면 main이 거절하는지, ../../secret 같은 경로를 넣으면 실패하는지, 원격 페이지에서 파일 저장 API가 없는지를 자동화하세요.

// 의사 코드: E2E에서 확인할 경계
expect(await mainWindow.evaluate(() => typeof window.desktop.saveText)).toBe('function')
expect(await remoteWindow.evaluate(() => 'desktop' in window)).toBe(false)

await expect(
  invokeFromSettingsWindow('file:save-text', { suggestedName: 'x', content: 'x' })
).rejects.toThrow('허용되지 않은 창')

보안은 “침입자를 완벽히 막는 고급 기능”이라기보다, 사고가 났을 때 피해 반경을 작게 만드는 설계입니다. context isolation은 침투한 스크립트가 preload로 건너오는 길을 좁히고, 좁은 bridge는 그 스크립트가 요청할 수 있는 기능을 줄이며, main 검증은 마지막 실행 권한을 잡습니다. 세 층이 같은 원칙을 반복하기 때문에 한 곳이 실수해도 전체 앱이 바로 열리지 않습니다.

마무리: 기능을 숨기지 말고 권한을 이름 붙인다

Electron 앱은 웹 UI와 데스크톱 권한을 함께 가집니다. 그래서 preload에 모든 편의 함수를 몰아넣는 방식은 처음에는 빠르지만, 제품이 자랄수록 무엇이 가능한지 아무도 자신 있게 말하기 어려워집니다.

좋은 기준은 단순합니다. renderer에는 사용자의 행동으로 설명할 수 있는 함수만 준다. preload는 그 함수를 명확한 IPC 요청으로 바꾼다. main은 어느 창이 어떤 데이터로 그 행동을 요청했는지 다시 확인한 뒤 실행한다. 이 흐름을 유지하면 보안과 개발 경험이 충돌하지 않습니다. 오히려 기능의 경계가 또렷해져서, 새 기능을 만들고 제거하고 테스트하는 속도가 더 안정됩니다.

공식 문서

최신 글