⚡ Electron5. 보안 모델권한 핸들러 & 네비게이션 가드

권한 핸들러 & 네비게이션 가드

이 문서가 답하는 질문: “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개:

  1. session.setPermissionRequestHandler — getUserMedia, geolocation, notification
  2. session.setPermissionCheckHandler — 동기 권한 확인
  3. webContents.setWindowOpenHandler — popup/새 창
  4. 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);
});

권한 종류 전수

permissionWeb API디폴트(미설정 시)
mediagetUserMedia (camera/mic)허용
geolocationnavigator.geolocation허용 (단, Google API key 없으면 실패)
notificationsNotification허용
midiWeb MIDI허용
pointerLockrequestPointerLock허용
fullscreenrequestFullscreen허용
openExternalshell.openExternal 호출 시허용
clipboard-readnavigator.clipboard.read허용
display-capturegetDisplayMedia (화면 공유)허용

모두 디폴트가 ‘허용’ — 핸들러 등록이 필수다.

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단계 디폴트 강화의 결정적 트리거였다. 그리고 setWindowOpenHandlernew-window 이벤트(deprecated)를 대체한 이유 — 새 창 생성이 디폴트 deny가 되도록 API 자체를 다시 그렸다.

권한·네비게이션 가드는 이 역사의 흔적이다 — 디폴트가 위험했던 시절의 유산을, 개발자가 정책 함수로 메우는 구조.


요약

  • 네 핸들러: permissionRequest + permissionCheck + setWindowOpenHandler + will-navigate.
  • 모든 권한 디폴트가 허용 — 등록 안 하면 다 통과.
  • webview 사용 시 will-attach-webview로 webPreferences 강제 수정.
  • origin allowlist가 권한 결정의 1차 필터.