보안은 나중에 붙이는 기능이 아니다
Electron 앱은 웹 페이지와 데스크톱 프로그램의 경계에 있다. 이 장점은 동시에 위험이 된다. 일반 웹 페이지에서 XSS가 발생하면 사용자의 화면이나 쿠키가 위험해질 수 있다. Electron에서 같은 문제가 발생했는데 renderer가 Node.js 권한까지 갖고 있다면 파일 읽기, 파일 쓰기, 외부 명령 실행으로 피해가 커질 수 있다.
그래서 Electron 보안의 출발점은 "사용자를 믿지 않는다"가 아니라 "renderer를 완전히 믿지 않는다"이다. 사용자가 입력한 값, 원격 서버에서 내려온 HTML, 외부 링크, 플러그인, 업데이트 파일은 모두 앱 내부로 들어오는 경계다. 이 경계를 좁게 만들고, main 프로세스에서 다시 검증하는 구조를 기본값으로 삼아야 한다.
Electron 공식 보안 권장사항도 원격 콘텐츠에 Node.js 권한을 주지 말고, contextIsolation과 sandbox를 사용하며, CSP와 내비게이션 제한, shell.openExternal 검증을 적용하라고 권한다.1 이 원칙을 코드 구조에 반영해 보자.
AI 생성 이미지
안전한 BrowserWindow 기본값
먼저 창을 만들 때 위험한 권한을 명시적으로 닫는다. 최신 Electron은 여러 보안 기본값을 개선해 왔지만, 프로젝트 코드에 의도를 드러내는 편이 낫다.
// main.js
const path = require('node:path')
const { app, BrowserWindow } = require('electron')
function createMainWindow() {
const win = new BrowserWindow({
width: 1100,
height: 760,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true
}
})
win.loadFile('index.html')
}
app.whenReady().then(createMainWindow)nodeIntegration: false는 renderer에서 require, fs, child_process 같은 Node.js 기능을 직접 쓰지 못하게 한다. 화면 코드는 브라우저 코드로 남고, 파일 저장이나 네이티브 기능은 main에 요청해야 한다.
contextIsolation: true는 preload와 웹 페이지의 JavaScript 실행 공간을 분리한다. preload가 window에 올려 둔 객체를 웹 페이지가 마음대로 바꾸거나, 내부 구현에 접근하는 일을 막기 위한 설정이다.
sandbox: true는 renderer를 더 제한된 실행 환경에 둔다. sandbox는 취약점이 생겼을 때 피해 범위를 줄이는 방어선이다. 이것만으로 안전해지는 것은 아니지만, 다른 설정과 함께 사용하면 공격자가 얻을 수 있는 권한을 줄인다.
webSecurity: true는 브라우저의 동일 출처 정책과 보안 제한을 유지한다. 개발 중 CORS 오류를 피하려고 webSecurity: false를 넣는 경우가 있는데, 이는 문제를 해결하는 것이 아니라 브라우저의 방어 장치를 끄는 일이다. API 서버의 CORS 설정을 고치거나 main에서 필요한 요청을 중계하는 방식으로 해결해야 한다.
원격 콘텐츠는 허용 목록으로 다룬다
Electron 앱은 loadURL로 원격 페이지를 띄울 수 있다. 하지만 원격 콘텐츠는 앱 내부 코드와 같은 신뢰 수준으로 다루면 안 된다. 서버가 침해되거나, 중간에 삽입된 스크립트가 실행되거나, 예상하지 못한 리다이렉트가 발생할 수 있기 때문이다.
원격 URL을 열어야 한다면 먼저 프로토콜과 출처를 검증한다.
const TRUSTED_ORIGINS = new Set([
'https://app.example.com'
])
function isTrustedUrl(rawUrl) {
try {
const url = new URL(rawUrl)
return url.protocol === 'https:' && TRUSTED_ORIGINS.has(url.origin)
} catch {
return false
}
}
function loadTrustedUrl(win, rawUrl) {
if (!isTrustedUrl(rawUrl)) {
throw new Error('허용되지 않은 URL입니다.')
}
return win.loadURL(rawUrl)
}검증은 문자열 포함 여부로 하면 안 된다. 예를 들어 https://app.example.com.attacker.test는 app.example.com을 포함하지만 전혀 다른 출처다. 반드시 new URL()로 파싱한 뒤 protocol, origin, hostname을 비교한다.
원격 콘텐츠에 브라우저 권한을 요청할 수 있는 기능이 있다면, 원격 콘텐츠를 로드하기 전에 session 단계에서도 제한한다.
const { session } = require('electron')
app.whenReady().then(() => {
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const pageUrl = webContents.getURL()
if (!isTrustedUrl(pageUrl)) {
return callback(false)
}
const allowed = permission === 'notifications'
callback(allowed)
})
})카메라, 마이크, 위치, 알림 같은 권한은 "필요할 때 묻는다"가 아니라 "어떤 출처에서 어떤 권한을 허용할지"를 코드로 정해야 한다. 앱 기능에 필요하지 않은 권한은 항상 거부한다.
CSP로 실행 가능한 코드를 줄인다
Content Security Policy, 줄여서 CSP는 브라우저에 "어디서 온 스크립트와 리소스를 실행해도 되는지" 알려 주는 정책이다. XSS를 완전히 없애지는 못하지만, 공격자가 삽입한 인라인 스크립트나 외부 스크립트가 실행될 가능성을 줄인다.
패키징된 HTML에는 다음처럼 엄격한 CSP를 둔다.
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'none'; frame-src 'none'">핵심은 script-src 'self'다. 앱에 포함된 스크립트만 실행하고, 임의의 외부 스크립트는 막는다. 가능하면 'unsafe-inline'과 'unsafe-eval'은 사용하지 않는다. 일부 개발 서버나 번들러가 개발 모드에서 이를 요구할 수 있지만, 배포 빌드에서는 별도 CSP를 적용해야 한다.
object-src 'none'은 오래된 플러그인 형태의 삽입 경로를 닫고, base-uri 'none'은 공격자가 <base> 태그로 상대 URL 기준을 바꾸는 일을 막는다. 페이지 안에서 iframe을 쓰지 않는다면 frame-src 'none'처럼 프레임 로드도 닫아 두는 편이 좋다.
다만 클릭재킹 방어에 쓰는 frame-ancestors는 meta 태그에 넣어도 적용되지 않는다. 이 지시문은 HTTP 응답 헤더로 전달해야 한다.2 원격 페이지나 커스텀 프로토콜 응답에 frame-ancestors 'none'을 적용하고 싶다면 HTML meta가 아니라 응답 헤더에서 설정한다.
프런트엔드 프레임워크를 사용할 때도 원칙은 같다. React, Vue, Svelte를 쓴다고 해서 XSS 위험이 사라지지 않는다. dangerouslySetInnerHTML, v-html, 임의 HTML 렌더링, 마크다운 미리보기 기능은 입력값 정제가 필요하다. 특히 클립보드에서 가져온 HTML이나 외부 API가 반환한 HTML은 그대로 넣지 않는다.
preload API는 작고 구체적으로 만든다
보안 설계에서 preload는 문이다. 문이 넓으면 renderer가 많은 권한을 갖는다. 문이 좁으면 renderer는 필요한 요청만 보낼 수 있다.
나쁜 패턴은 ipcRenderer 자체를 노출하는 것이다.
// 피해야 할 예
contextBridge.exposeInMainWorld('electron', {
ipcRenderer
})이렇게 하면 renderer는 앱의 모든 IPC 채널을 호출할 수 있다. 화면 코드에 XSS가 생겼을 때 공격자는 내부 채널 이름을 추측하거나 개발자 도구에서 확인해 main 기능을 직접 호출할 수 있다. 채널 이름을 숨기는 것은 보안 장치가 아니다.
대신 기능 단위 API만 노출한다.
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
function normalizeNoteInput(input) {
if (!input || typeof input !== 'object') {
throw new TypeError('노트 데이터가 필요합니다.')
}
const title = String(input.title ?? '').trim()
const body = String(input.body ?? '')
if (title.length < 1 || title.length > 120) {
throw new RangeError('제목은 1자 이상 120자 이하여야 합니다.')
}
if (body.length > 200_000) {
throw new RangeError('본문이 너무 큽니다.')
}
return { title, body }
}
contextBridge.exposeInMainWorld('notes', {
save(input) {
const note = normalizeNoteInput(input)
return ipcRenderer.invoke('notes:save', note)
},
list() {
return ipcRenderer.invoke('notes:list')
}
})preload에서 1차 검증을 하면 renderer 코드의 실수를 빨리 발견할 수 있다. 하지만 이것만 믿어서는 안 된다. preload 역시 앱 코드의 일부일 뿐이며, 최종 권한은 main이 갖고 있다. main의 IPC handler에서 다시 검증해야 한다.
// main.js
const fs = require('node:fs/promises')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const crypto = require('node:crypto')
const { app, ipcMain } = require('electron')
const APP_ENTRY_URL = pathToFileURL(path.join(__dirname, 'index.html')).toString()
function isTrustedSenderUrl(rawUrl) {
try {
const url = new URL(rawUrl)
if (url.protocol === 'file:') {
return url.href.split('#')[0] === APP_ENTRY_URL
}
return isTrustedUrl(rawUrl)
} catch {
return false
}
}
function assertTrustedSender(event) {
const url = event.senderFrame?.url ?? ''
if (!isTrustedSenderUrl(url)) {
throw new Error('신뢰할 수 없는 sender입니다.')
}
}
function validateNote(note) {
if (!note || typeof note !== 'object') throw new TypeError('잘못된 노트입니다.')
if (typeof note.title !== 'string') throw new TypeError('제목 형식이 잘못되었습니다.')
if (typeof note.body !== 'string') throw new TypeError('본문 형식이 잘못되었습니다.')
if (note.title.length < 1 || note.title.length > 120) throw new RangeError('제목 길이가 잘못되었습니다.')
if (note.body.length > 200_000) throw new RangeError('본문이 너무 큽니다.')
}
ipcMain.handle('notes:save', async (event, note) => {
assertTrustedSender(event)
validateNote(note)
const notesDir = path.join(app.getPath('userData'), 'notes')
await fs.mkdir(notesDir, { recursive: true })
const fileName = `${crypto.randomUUID()}.json`
const filePath = path.join(notesDir, fileName)
await fs.writeFile(filePath, JSON.stringify(note, null, 2), 'utf8')
return { ok: true, id: fileName }
})예시는 단일 index.html에서 IPC를 보내는 구조를 기준으로 sender를 확인한다. 실제 앱에서 여러 로컬 HTML, 커스텀 app:// 프로토콜, 신뢰된 원격 페이지를 함께 쓴다면 각각을 허용 목록으로 분리한다. 중요한 것은 "어떤 renderer가 이 채널을 호출해도 되는지"를 main에서 다시 판단하는 것이다.
파일 이름을 사용자 입력에서 직접 만들지 않는 점도 중요하다. 사용자가 입력한 제목을 파일 경로로 쓰면 ../ 같은 경로 조작, 운영체제별 금지 문자, 지나치게 긴 파일명 문제가 생긴다. 사용자 입력은 데이터로 저장하고, 실제 파일명은 앱이 만든 안전한 값으로 정한다.
내비게이션과 새 창을 막는다
데스크톱 앱의 본문 영역이 갑자기 외부 로그인 페이지나 광고 페이지로 바뀌면 사용자는 그것을 앱의 일부로 믿을 수 있다. 피싱 위험이 생긴다. 따라서 앱 창 안에서 허용하지 않은 페이지로 이동하지 못하게 막아야 한다.
const { shell } = require('electron')
const EXTERNAL_ORIGINS = new Set([
'https://docs.example.com',
'https://support.example.com'
])
function isAllowedExternalUrl(rawUrl) {
try {
const url = new URL(rawUrl)
return url.protocol === 'https:' && EXTERNAL_ORIGINS.has(url.origin)
} catch {
return false
}
}
function protectNavigation(win) {
win.webContents.on('will-navigate', (event, url) => {
if (!isTrustedUrl(url)) {
event.preventDefault()
}
})
win.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedExternalUrl(url)) {
shell.openExternal(url)
}
return { action: 'deny' }
})
}will-navigate는 현재 창이 다른 주소로 이동하려 할 때 개입하는 지점이다. 앱 내부에서 이동할 필요가 없는 구조라면 기본적으로 모두 막고, 필요한 주소만 허용한다. 로컬 라우팅이나 커스텀 프로토콜을 사용한다면 내부 화면 URL과 외부 URL을 같은 함수로 대충 처리하지 말고, 각각 별도의 허용 규칙을 둔다.
window.open이나 링크의 target='_blank'는 새 창을 만들 수 있다. Electron에서는 이것이 새로운 BrowserWindow 생성으로 이어질 수 있으므로 setWindowOpenHandler에서 정책을 정한다. 대부분의 외부 문서는 앱 창 안에 띄우지 말고 기본 브라우저로 넘기는 편이 안전하다.
shell.openExternal도 입력값 검증 없이 호출하면 안 된다. https:가 아닌 프로토콜은 운영체제의 다른 앱을 실행할 수 있다. 앱이 정말 mailto:나 특정 커스텀 프로토콜을 지원해야 한다면 허용 목록에 명시하고, 사용자에게 열릴 대상을 보여 준 뒤 실행한다.
다운로드, 업데이트, 플러그인은 코드 유입 경로다
보안 사고는 로그인 화면이나 IPC에서만 생기지 않는다. 파일 다운로드, 자동 업데이트, 플러그인 기능은 앱 밖의 무언가가 로컬 환경으로 들어오는 통로다.
다운로드 기능을 제공한다면 파일 출처, 확장자, 크기, 저장 위치를 제한한다. 다운로드 완료 후 자동 실행은 피한다. 사용자가 선택한 폴더에 저장하더라도 최종 경로가 의도한 위치 안에 있는지 확인해야 한다. 실행 파일, 스크립트, 압축 파일은 특히 조심한다.
자동 업데이트는 더 민감하다. 업데이트 파일은 사실상 새 앱 코드다. HTTPS만으로는 부족하다. 코드 서명, 업데이트 서명 검증, 배포 채널 고정, 다운그레이드 방지, 릴리스 서버 접근 권한 관리가 함께 필요하다. 사용자가 입력한 임의 URL에서 업데이트를 받는 구조는 만들지 않는다.
플러그인이나 확장 기능을 제공할 때도 같은 기준을 적용한다. 플러그인은 편의 기능이 아니라 제3자 코드 실행 모델이다. 플러그인이 접근할 수 있는 API를 manifest로 제한하고, 파일 시스템·네트워크·클립보드 같은 권한은 명시적으로 요청하게 만든다. 가능하면 별도 프로세스에서 실행하고, 앱 내부 IPC 채널을 그대로 공유하지 않는다.
실무 체크리스트
새 창을 만들 때마다 다음 항목을 확인한다.
nodeIntegration: false를 유지한다.contextIsolation: true를 유지한다.sandbox: true를 사용한다.webSecurity: false를 넣지 않는다.allowRunningInsecureContent,experimentalFeatures, 불필요한 Blink 기능을 켜지 않는다.preload는 기능 단위 API만 노출한다.
ipcRenderer,fs,path,shell전체를 renderer에 넘기지 않는다.IPC payload는 preload와 main에서 모두 검증한다.
main은 IPC sender의 출처를 확인한다.
원격 URL은
https:와 허용된 origin만 연다.CSP를 배포 빌드에 적용한다.
will-navigate,window.open,shell.openExternal을 제한한다.다운로드와 업데이트 파일은 서명, 출처, 저장 위치를 검증한다.
보안은 기능 개발이 끝난 뒤 점검표로 덧붙이는 작업이 아니다. BrowserWindow를 만드는 순간, preload API를 설계하는 순간, IPC 채널 이름을 정하는 순간 이미 보안 구조가 결정된다. Electron 앱은 강력한 권한을 가질 수 있으므로 기본값은 좁고 명확해야 한다. 필요한 권한만 열고, 들어오는 값은 검증하고, 외부에서 온 코드는 앱 내부 코드처럼 취급하지 않는 것. 이 습관이 취약한 앱과 오래 유지되는 앱을 가른다.
참고
2MDN Web Docs, Content-Security-Policy: frame-ancestors directive
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.