⚡ Electron4. 네이티브 통합02 · shell · clipboard · powerMonitor

02 · shell · clipboard · powerMonitor

이 문서가 답하는 질문: Electron이 OS의 다른 앱·시스템 이벤트·클립보드에 닿는 API들은 무엇이고, 왜 URL 한 줄이 RCE가 되는가? API: shell, clipboard, nativeImage, powerMonitor, systemPreferences, screen 공통 함정: 사용자 입력을 검증 없이 OS로 그대로 넘기면 통제권을 잃는다.


한 줄 답 (Pyramid Top)

shell·clipboard·powerMonitor는 Electron이 OS의 바깥과 대화하는 얇은 어댑터다. 얇기 때문에 — 사용자 입력 한 줄이 그대로 OS에 흘러간다. 그래서 모든 호출이 검증 단계를 가져야 한다 — openExternal(url) 한 줄로 javascript:... 한 줄이 renderer에서 OS 명령이 된다.


Why — 왜 바깥과의 대화에 별도 API가 필요한가

Electron 안에서 내 창을 다루는 건 BrowserWindow로 충분하다. 하지만 사용자의 클릭이 내 앱 바깥으로 흘러가야 할 때가 자주 있다.

사용자 의도호출해야 하는 API
”이 링크를 기본 브라우저에서 열어줘”shell.openExternal(url)
”이 폴더를 Finder/Explorer에서 보여줘”shell.openPath(dir) 또는 showItemInFolder
”이 파일을 기본 앱으로 열어줘”shell.openPath(file)
”휴지통으로 보내줘”shell.trashItem(path)
”복사해줘” / “붙여넣을 거 줘”clipboard.writeText / readText
”이 PNG 데이터를 아이콘으로 써줘”nativeImage.createFromBuffer
”절전모드 들어가면 알려줘”powerMonitor.on('suspend', ...)
”다크 모드인지 알려줘”nativeTheme.shouldUseDarkColors

웹의 window.open(url)브라우저 안에서 새 탭을 연다. shell.openExternalOS의 default handler를 호출한다 — 완전히 다른 동작이다.

바깥으로 나가는 API는 내가 통제권을 잃는 순간이다. 그래서 무엇을 누구에게 넘기는지를 가장 까다롭게 검증해야 한다.


How — 어떻게 동작하는가

shell.openExternal — 가장 위험한 한 줄

import { shell } from 'electron';
 
// ❌ 절대 금지
ipcMain.handle('open-url', (_e, url) => shell.openExternal(url));
 
// 공격: url = "javascript:alert(navigator.userAgent)"
// 구버전 macOS/Linux에서 default browser가 javascript: 스킴을 실행
// 또는 url = "calc.exe" / "open -a Calculator"  ← OS 명령 실행

shell.openExternalOS의 URL handler를 호출한다. macOS는 open(1), Linux는 xdg-open, Windows는 ShellExecute. 이들은 https: 외에도 임의의 등록된 스킴을 따른다 — file: · javascript: · vbscript: · ms-msdt:(Follina 사례).

필수 검증:

import { URL } from 'node:url';
 
const ALLOWED = new Set(['http:', 'https:', 'mailto:']);
 
ipcMain.handle('open-url', (_e, raw) => {
  let u;
  try {
    u = new URL(raw);
  } catch {
    throw new Error('invalid url');
  }
  if (!ALLOWED.has(u.protocol)) {
    throw new Error(`disallowed protocol: ${u.protocol}`);
  }
  return shell.openExternal(u.toString());
});

*허용 목록(allowlist)*이 default. javascript:·file:명시적으로 거부해도 우회 가능성이 있어서 whitelist가 안전.

shell.openPath경로에 대한 같은 함정

// ❌
shell.openPath(req.body.path);
// path = '/etc/passwd' → 사용자에게 그 파일이 보임
 
// ✅
const base = app.getPath('downloads');
const resolved = path.resolve(base, userKey);
if (!resolved.startsWith(base + path.sep)) throw new Error('blocked');
shell.openPath(resolved);

openPath디렉토리든 파일이든 OS default로 연다. Renderer가 임의 경로를 열 수 있으면 fs를 안 거치고도 정보 노출이 가능.

shell.trashItem (구 moveItemToTrash)

await shell.trashItem('/Users/raw/Downloads/old.zip');
// macOS: ~/.Trash 로 이동
// Windows: $RECYCLE.BIN
// Linux: ~/.local/share/Trash (XDG)

복구 가능 — unlink 대신 항상 trashItem을 default로. 사용자의 “되돌리고 싶다”는 99% 여기서 해결.

shell.beep

OS 비프음. 접근성 알림에 유용하지만 macOS는 시스템 사운드 off면 무음.

clipboard — 가장 짧지만 가장 자주 새는 API

import { clipboard } from 'electron';
 
clipboard.writeText('hello');
const t = clipboard.readText(); // 'hello'
 
// 이미지
clipboard.writeImage(nativeImage.createFromPath('/tmp/foo.png'));
const img = clipboard.readImage();
 
// HTML / RTF / bookmark
clipboard.write({
  text: 'fallback',
  html: '<b>hello</b>',
  rtf: '{\\rtf1...}',
  bookmark: 'Click me',
});

Renderer에서 임의로 clipboard.read를 부르면 다른 앱의 비밀(비번 매니저가 잠깐 둔 비번 등)을 가져갈 수 있다. clipboardMain에서만, 명시적 사용자 의도(메뉴/단축키)에서만 호출.

macOS Sonoma+는 클립보드 읽기마다 OS 알림이 뜬다 — 이걸 무차별 호출 탐지 기제로 쓰면 됨.

nativeImage — 메모리 안 이미지

import { nativeImage } from 'electron';
 
const icon = nativeImage.createFromPath('/path/to/icon.png');
const buf = icon.toPNG();           // Buffer
const dataUrl = icon.toDataURL();    // data:image/png;base64,...
const resized = icon.resize({ width: 16 });
 
// 다크 모드 대응
const tray = nativeImage.createFromPath('icon.png');
tray.setTemplateImage(true); // macOS: 자동 흑백 반전

setTemplateImage(true)가 macOS 메뉴바 아이콘의 유일한 정답 — 안 켜면 라이트/다크 모드 전환에 색이 안 바뀐다.

powerMonitor — 시스템 전원 이벤트

import { powerMonitor } from 'electron';
 
app.whenReady().then(() => {
  powerMonitor.on('suspend', () => {
    // 슬립 들어감 — 진행 중인 IO 중단 / 연결 종료
  });
  powerMonitor.on('resume', () => {
    // 깨어남 — 토큰 갱신 / 재연결
  });
  powerMonitor.on('on-ac', () => {/* 전원 연결 */});
  powerMonitor.on('on-battery', () => {/* 배터리 */});
  powerMonitor.on('lock-screen', () => {/* 잠금 */});
  powerMonitor.on('unlock-screen', () => {/* 해제 */});
});
 
// 사용자 idle 시간
const idleSec = powerMonitor.getSystemIdleTime();
const state = powerMonitor.getSystemIdleState(60); // 'active'|'idle'|'locked'|'unknown'

대부분 버그resume 처리 누락이다 — long-lived WebSocket이 suspend 동안 죽어 있는데 앱은 모름.

systemPreferences — OS 설정 조회

import { systemPreferences } from 'electron';
 
// macOS dark mode
systemPreferences.isDarkMode(); // ❌ deprecated → nativeTheme.shouldUseDarkColors
 
// 강조 색상
systemPreferences.getAccentColor(); // 'a64dffff' (macOS/Windows)
 
// macOS 권한 체크 (마이크/카메라/위치/스크린 등)
const cam = systemPreferences.getMediaAccessStatus('camera');
// 'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'
 
if (cam !== 'granted') {
  await systemPreferences.askForMediaAccess('camera');
}

screen — 디스플레이 정보

import { screen } from 'electron';
 
const primary = screen.getPrimaryDisplay();
// { id, bounds, workArea, size, workAreaSize, scaleFactor, rotation, ... }
 
const all = screen.getAllDisplays();
const cursor = screen.getCursorScreenPoint(); // {x, y}

scaleFactor가 macOS Retina에서 2, Windows 4K 200%에서 2 — 픽셀과 포인트가 다른 단위임을 의식해야.


What — 구체 사양 / CLI

URL 검증 — 완벽한 정규식은 없다

// ❌ regex로 풀려고 하지 말 것
const re = /^https?:\/\/.+/; // 우회 쉬움
 
// ✅ WHATWG URL parser 사용
const u = new URL(input);
const ok = ['http:', 'https:'].includes(u.protocol)
        && !u.username && !u.password    // userinfo 차단
        && u.hostname.length > 0;

userinfo(user:pass@host)는 phishing의 단골 수단 — 차단 추천.

빈도 제한

// 사용자 입력 1초당 1번 외엔 거부 (자동 클릭/스크립트 방지)
const lastCall = new Map();
function throttle(channel, userId, ms = 1000) {
  const k = `${channel}:${userId}`;
  const now = Date.now();
  if (now - (lastCall.get(k) ?? 0) < ms) throw new Error('rate limited');
  lastCall.set(k, now);
}

showItemInFolder vs openPath

호출동작
shell.openPath('/Users/raw/file.txt')기본 앱으로 파일을 연다 (텍스트면 에디터)
shell.showItemInFolder('/Users/raw/file.txt')Finder/Explorer에서 해당 파일이 선택된 상태로 폴더를 연다

“Reveal in Finder” 같은 UX는 항상 showItemInFolder. 사용자가 내용을 보고 싶은 게 아니라 위치를 확인하고 싶을 때가 더 흔하다.

macOS open 명령 옵션 (참고)

shell.openPath는 macOS에서 open(1)을 부른다. 그래서 macOS에서만 가능한 옵션이 있다:

open -a "Visual Studio Code" foo.txt   # 특정 앱으로
open -R foo.txt                         # Reveal in Finder (= showItemInFolder)
open -t foo.txt                         # default text editor
open -e foo.txt                         # TextEdit 강제
open --background app.bundle            # 포커스 가져가지 않고

Electron API로는 못 부른다 — 특정 앱으로 열려면 child_process.spawn('open', ['-a', ...]) (→ 07-cli-and-child-process).

app.setAsDefaultProtocolClient

내 앱이 특정 URL 스킴의 핸들러가 된다.

// 'myapp://...' 가 내 앱을 열도록
app.setAsDefaultProtocolClient('myapp');
 
// 사용자가 브라우저에서 myapp://login/abc를 클릭하면
// macOS: 'open-url' 이벤트
app.on('open-url', (e, url) => {
  e.preventDefault();
  // url = 'myapp://login/abc'
  handleDeepLink(url);
});
 
// Windows/Linux: 두 번째 인스턴스가 argv로 받음 — single-instance lock 필요

OAuth 콜백 / 딥링크의 유일한 표준shell.openExternal로 보낸 OAuth 페이지가 돌아올 때 이걸 받는다.


What-if — 잘못 다루면 어떻게 깨지는가

1) openExternal RCE (CVE-2018-1000136 계열)

2018년 Electron 1.7.x ~ 1.8.4: nodeIntegration:false여도 iframe에서 window.open 또는 javascript: 스킴 URLopenExternal로 보내면 Node 컨텍스트에서 실행. 대부분의 1.x 시대 앱이 영향받음.

대응: 모든 openExternal 호출에 URL allowlist. Electron 자체도 5.x부터 default로 javascript:를 막지만 방어선은 두 겹.

2) Follina (CVE-2022-30190, ms-msdt:)

Windows의 ms-msdt: 스킴이 명령 실행으로 이어지는 버그. Electron이 직접 부르진 않지만 openExternal로 사용자 입력 URL을 그대로 보내면 영향. allowlist가 그래서 필수.

3) shell.openPath(file) → 임의 코드 실행

Windows에서 openPath.bat · .lnk · .scr를 열면 그 스크립트가 실행된다. 사용자가 다운로드 받은 파일을 바로 여는 UX는 위험. 항상 showItemInFolder로 우선 노출.

4) 클립보드 비밀 탈취

// ❌ Renderer에서 polling
setInterval(() => {
  const t = clipboard.readText();
  if (t) send(t); // 외부로 송신
}, 100);

비밀번호 매니저가 클립보드에 비번을 잠시 둘 때를 훔쳐 갈 수 있다. macOS Sonoma+는 알림으로 visible하지만 코드로 차단하는 게 정답 — clipboard는 Main에서만 호출, 사용자 의도(메뉴 클릭) 시에만.

5) powerMonitor resume 누락 → 좀비 연결

// ❌ suspend만 등록
powerMonitor.on('suspend', () => ws.close());
// resume 시 ws가 다시 안 열림 → 사용자는 "오프라인"이라 느낌
 
// ✅
powerMonitor.on('suspend', () => ws.close());
powerMonitor.on('resume', () => ws.reconnect());

6) macOS getMediaAccessStatus 누락

마이크/카메라 권한이 기본 거부. askForMediaAccess로 prompt를 띄우거나 사용자에게 시스템 설정을 안내해야 함. 안 그러면 getUserMedia조용히 실패하고 사용자는 “음소거된 줄 모름”.

7) nativeImage.setTemplateImage 누락

macOS 트레이 아이콘이 라이트 모드에서 보이고 다크 모드에서 안 보임. 다크 배경에 검은 아이콘. Renderer가 그린 이미지면 setTemplateImage(true) 하나로 해결.

8) screen 좌표가 유닛이 안 맞음

Windows 다중 모니터에서 마이너스 좌표가 정상 (왼쪽 모니터). getBounds().x < 0에러로 처리하면 좌측 모니터 사용자가 깨짐.


Insight — 흥미로운 이야기

“Electron Spectron 시대의 어두운 클립보드 leak”

2020년 iOS 14가 클립보드 읽기 알림을 추가했더니 TikTok·Reddit·Fox News·NPR 등 50+ 앱수십 초마다 클립보드를 읽고 있던 게 발각. Electron 앱들도 같은 패턴 — Slack/Discord가 텍스트 박스 포커스마다 clipboard.readText를 부르고 있었다. 결과: macOS Sonoma의 클립보드 알림이 그래서 추가됐다. 그 알림이 안 뜨려면 명시적 paste 액션에서만 읽어야 한다.

“openExternal RCE는 한 줄로 끝났다”

2018년 트위치의 보안 연구자 Brendan Scarvellelectron@1.8.3에서 발견 — <a target="_blank" href="javascript:require('child_process').exec('calc')">. renderer 안의 클릭 한 번으로 calc.exe 실행. 패치는 Electron이 openExternal에 javascript:/file: 기본 거부를 추가하는 것. 하지만 2018년 이전 빌드는 영구히 취약 — 그래서 Electron 메이저 업그레이드가 보안 운영의 핵심이 됐다 (→ 06-packaging-distribution).

“powerMonitor의 가장 잘 쓰이는 이벤트는 lock-screen”

Slack은 lock-screen 이벤트로 “away” 상태로 전환. resume도 active로 자동 복귀. 시스템 idle까지 같이 보면 수동 ‘away’ 토글이 필요 없는 UX가 된다.

“nativeImage는 PNG/JPEG 외에 ICO/ICNS도 만든다”

Windows 트레이 아이콘은 .ico가 표준 (다중 해상도 임베드). macOS dock 아이콘은 .icns. nativeImage.createFromPath둘 다 자동 인식빌드 타임에 만들지 말고 런타임 nativeImage로 동적 생성도 가능 (테마별 아이콘).


요약 + Mermaid

shell·clipboard·powerMonitorOS의 바깥과 대화하는 얇은 어댑터다. 얇기 때문에 사용자 입력 검증이 전부 — openExternal(url)whitelist, openPath(path)base 디렉토리 prefix 검증, clipboard사용자 의도가 명시된 순간에만. 그리고 powerMonitor의 resume 처리를 빼먹지 말 것 — 오프라인 같지만 사실 좀비 연결인 사용자가 가장 많이 발생하는 자리.