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.openExternal은 OS의 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.openExternal은 OS의 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를 부르면 다른 앱의 비밀(비번 매니저가 잠깐 둔 비번 등)을 가져갈 수 있다.
clipboard는 Main에서만, 명시적 사용자 의도(메뉴/단축키)에서만 호출.
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: 스킴 URL을 openExternal로 보내면 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 Scarvell이
electron@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·powerMonitor는 OS의 바깥과 대화하는 얇은 어댑터다. 얇기 때문에 사용자 입력 검증이 전부 —openExternal(url)은 whitelist,openPath(path)는 base 디렉토리 prefix 검증,clipboard는 사용자 의도가 명시된 순간에만. 그리고powerMonitor의 resume 처리를 빼먹지 말 것 — 오프라인 같지만 사실 좀비 연결인 사용자가 가장 많이 발생하는 자리.