권한 핸들러 & 네비게이션 가드
이 문서가 답하는 질문: “Electron 앱에서 카메라·마이크 같은 권한 요청과 외부 URL 네비게이션을 어떻게 차단·허용하는가?” 한 줄 답 (Pyramid Top): “브라우저가 사용자에게 묻는 일을 Electron은 개발자가 명시적으로 정책 함수로 답해야 한다 — 디폴트가 ‘deny’가 아니라 ‘allow’이기 때문이다.”
Why — 왜 존재하는가
브라우저는 navigator.mediaDevices.getUserMedia()를 호출하면 사용자에게 권한 다이얼로그를 띄운다. Electron의 Renderer도 같은 Chromium이라 같은 코드가 동작하지만 — 권한 결정 주체가 다르다.
| 결정 주체 | 브라우저 | Electron |
|---|---|---|
| 카메라/마이크 | 사용자 (브라우저 UI) | 디폴트 허용, 개발자가 핸들러 등록 안 하면 |
| 외부 링크 | 새 탭/창 | 디폴트 새 BrowserWindow 생성 — 통제 안 됨 |
| popup window | 새 탭 | webContents 생성 — XSS 통로 가능 |
| download | 사용자 다이얼로그 | 자동 진행 — 핸들러 필요 |
디폴트가 너무 관대하다. 이걸 좁히는 게 보안의 핵심 작업.
How — 어떻게 동작하는가
핵심 API 4개:
session.setPermissionRequestHandler— getUserMedia, geolocation, notificationsession.setPermissionCheckHandler— 동기 권한 확인webContents.setWindowOpenHandler— popup/새 창webContents.on('will-navigate', ...)— 같은 창에서의 네비게이션
What — 구체 사양·수치·예시
권한 요청 핸들러 — 안전한 디폴트
const { session } = require('electron');
const ALLOWED_PERMISSIONS = new Set(['notifications']);
const ALLOWED_ORIGINS = new Set(['app://./', 'https://app.mycompany.com']);
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
const origin = new URL(details.requestingUrl).origin;
if (!ALLOWED_ORIGINS.has(origin)) {
console.warn(`Permission ${permission} denied for ${origin}`);
return callback(false);
}
if (ALLOWED_PERMISSIONS.has(permission)) {
return callback(true);
}
callback(false);
});
// 동기 체크 — Permissions API용
session.defaultSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
return ALLOWED_ORIGINS.has(requestingOrigin) && ALLOWED_PERMISSIONS.has(permission);
});권한 종류 전수
| permission | Web API | 디폴트(미설정 시) |
|---|---|---|
media | getUserMedia (camera/mic) | 허용 |
geolocation | navigator.geolocation | 허용 (단, Google API key 없으면 실패) |
notifications | Notification | 허용 |
midi | Web MIDI | 허용 |
pointerLock | requestPointerLock | 허용 |
fullscreen | requestFullscreen | 허용 |
openExternal | shell.openExternal 호출 시 | 허용 |
clipboard-read | navigator.clipboard.read | 허용 |
display-capture | getDisplayMedia (화면 공유) | 허용 |
모두 디폴트가 ‘허용’ — 핸들러 등록이 필수다.
setWindowOpenHandler — popup/새 창
const ALLOWED_TARGET_ORIGINS = new Set(['https://app.mycompany.com']);
mainWindow.webContents.setWindowOpenHandler(({ url, frameName, features }) => {
const target = new URL(url);
// 외부 링크 → 브라우저로 열고 새 창 차단
if (target.protocol === 'https:' && !ALLOWED_TARGET_ORIGINS.has(target.origin)) {
shell.openExternal(url);
return { action: 'deny' };
}
// 신뢰 origin만 새 BrowserWindow 허용 — webPreferences 강제
if (ALLOWED_TARGET_ORIGINS.has(target.origin)) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
}
}
};
}
return { action: 'deny' };
});will-navigate — 같은 창 네비게이션
mainWindow.webContents.on('will-navigate', (event, url) => {
const target = new URL(url);
if (target.origin !== 'app://.') {
event.preventDefault();
shell.openExternal(url); // 외부 브라우저로
}
});
// `will-redirect`도 같이 — 리다이렉트로 우회 시도 차단
mainWindow.webContents.on('will-redirect', (event, url) => {
const target = new URL(url);
if (target.origin !== 'app://.') {
event.preventDefault();
}
});will-attach-webview — <webview> 태그 가드
app.on('web-contents-created', (event, contents) => {
contents.on('will-attach-webview', (event, webPreferences, params) => {
// webview에 nodeIntegration 끼우려는 시도 차단
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
if (!params.src.startsWith('https://trusted.com/')) {
event.preventDefault();
}
});
});download 가드
session.defaultSession.on('will-download', (event, item, webContents) => {
// 자동 다운로드 차단 — 사용자 확인 후 진행
const url = item.getURL();
if (!url.startsWith('https://app.mycompany.com/')) {
event.preventDefault();
return;
}
item.setSavePath(path.join(app.getPath('downloads'), item.getFilename()));
});What-if — 잘못 쓰면
- 함정 1: 핸들러 미등록 → 임의 origin이 카메라 켬. 신고된 CVE 다수.
- 함정 2:
setWindowOpenHandler없이 외부 사이트가 popup 열음 → 그 popup이nodeIntegration: true상속 (구버전) → RCE. - 함정 3:
will-navigate만 막고will-redirect안 막음 → 외부 사이트가 302로 우회 가능. - 함정 4:
<webview>에 preload 끼울 수 있음 → guest에서 host 권한 탈취.web-contents-created에서 강제 제거. - 함정 5:
target="_blank"링크 → setWindowOpenHandler 없으면 디폴트 새 창 — 허용된 origin 외에는 무조건 deny.
Insight — 흥미로운 이야기
“Atom의 라쿠텐 RCE — 2017년”
Atom 에디터(Electron 기반)에서 MathML 파서의 XSS를 발견한 연구자가 —
target="_blank"링크를 통해 새 BrowserWindow를 nodeIntegration: true로 열 수 있음을 입증했다. 결과는 마크다운 프리뷰 한 번 클릭으로 OS 명령 실행.이 사건은 Electron 5에서 nodeIntegration 디폴트 false, Electron 12에서 contextIsolation 디폴트 true, Electron 22에서 sandbox 디폴트 true로 이어진 3단계 디폴트 강화의 결정적 트리거였다. 그리고
setWindowOpenHandler가new-window이벤트(deprecated)를 대체한 이유 — 새 창 생성이 디폴트 deny가 되도록 API 자체를 다시 그렸다.권한·네비게이션 가드는 이 역사의 흔적이다 — 디폴트가 위험했던 시절의 유산을, 개발자가 정책 함수로 메우는 구조.
요약
- 네 핸들러:
permissionRequest+permissionCheck+setWindowOpenHandler+will-navigate. - 모든 권한 디폴트가 허용 — 등록 안 하면 다 통과.
webview사용 시will-attach-webview로 webPreferences 강제 수정.- origin allowlist가 권한 결정의 1차 필터.