커스텀 protocol & scheme
이 문서가 답하는 질문: “로컬 자원을 안전하게 로드하려면 왜
file://를 쓰면 안 되고, 어떻게 커스텀 스킴을 등록하는가?” 한 줄 답 (Pyramid Top): “file://직접 노출은 same-origin과 CSP를 깨므로,app://같은 커스텀 protocol로 디렉토리 범위를 한정해 정적 자원을 서빙한다.”
Why — 왜 존재하는가
웹 페이지가 자원을 부르는 방법은 URL이다. Electron의 로컬 자원도 URL이 있어야 — <img src="...">, <link rel="stylesheet">가 동작한다. 가장 단순한 방법은 file:///Users/.../assets/logo.png이지만, 그건 디스크 전체를 origin으로 만들어 보안이 무너진다.
| 문제 | 해법 | 한계 |
|---|---|---|
모든 로컬 자원이 file:// | scheme = “file”, origin null | XHR 못 함, CSP 우회됨 |
| HTTP 서버 띄우기 | localhost:0 (랜덤 포트) | 포트 충돌·다른 앱이 접근 가능 |
| 커스텀 scheme | protocol.handle('app', ...) | 원하는 만큼만 노출 |
웹에서는 자원 출처가 명시적 URL이다. Electron은 로컬 디스크와 가상 origin 사이를 직접 설계해야 한다.
How — 어떻게 동작하는가
Electron 25+에서는 standard protocol.handle(scheme, handler) API가 표준이다. Fetch API와 동일한 Request/Response 객체를 다룬다.
What — 구체 사양·수치·예시
protocol.handle (v25+)
const { app, protocol, net } = require('electron');
const path = require('path');
const { pathToFileURL } = require('url');
// 1) scheme 사전 등록 (app.ready 이전)
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }
]);
app.whenReady().then(() => {
// 2) 핸들러 등록
protocol.handle('app', (request) => {
const url = new URL(request.url); // app://./index.html
const filePath = path.join(__dirname, 'dist', url.pathname);
// 디렉토리 escape 방지
if (!filePath.startsWith(path.join(__dirname, 'dist'))) {
return new Response('forbidden', { status: 403 });
}
return net.fetch(pathToFileURL(filePath).toString());
});
win.loadURL('app://./index.html');
});권한(privileges) 표
| 권한 | 의미 |
|---|---|
standard | URL 파싱이 http처럼 동작 (path/query 인식) |
secure | secure context 부여 (Service Worker, crypto.subtle 가능) |
supportFetchAPI | fetch('app://...') 허용 |
corsEnabled | CORS 검사 활성화 |
stream | 응답을 stream으로 처리 |
디렉토리 escape 방어 — 필수 패턴
const root = path.join(__dirname, 'dist');
const resolved = path.normalize(path.join(root, url.pathname));
if (!resolved.startsWith(root + path.sep)) {
return new Response('forbidden', { status: 403 });
}
../../../etc/passwd같은 path traversal을 막는다. 반드시 normalize + startsWith 확인.
deprecated: registerFileProtocol / registerStreamProtocol
// v25 이전 — 여전히 동작하지만 새 API로 마이그레이션 권장
protocol.registerFileProtocol('app', (request, callback) => {
callback({ path: path.join(__dirname, 'dist', new URL(request.url).pathname) });
});What-if — 잘못 쓰면
- 함정 1: scheme을
app.ready이후에 register → 무시됨.registerSchemesAsPrivileged는 반드시 ready 전. - 함정 2: path traversal 방어 없음 → 사용자가
app://./..%2F..%2F.env같은 URL로 디스크 임의 파일 읽기. CVE 사례 다수. - 함정 3:
file://을 그대로 사용 → Service Worker 못 씀, fetch CORS 깨짐, secure context 아님 (Web Crypto·Notification 제한). - 함정 4:
webSecurity: false로 우회 시도 → 다른 출처의 XHR도 무제한 허용되어 XSS → exfiltration 통로가 열림.
Insight — 흥미로운 이야기
“VS Code는
vscode-file://를 자기 protocol로 쓴다”Visual Studio Code의 dev tools를 열어보면 모든 정적 자원이
vscode-file://로 로드된다. 이는 Microsoft가 file:// 의 위험성을 가장 절절히 경험한 결과다 — 초기 버전에서 file:// 기반으로 짠 뒤 같은 origin에 외부 콘텐츠가 섞이는 사고가 있었고, 그 뒤로 커스텀 scheme이 기본 패턴이 됐다.Electron 공식 문서가 권장하는 베스트 프랙티스의 #18번 항목이 “Do not load remote content with file:// or http:// protocol”인 이유가 여기에 있다. 커스텀 protocol은 origin을 내가 정한다는 점이 핵심 — 그래야 CSP·CORS·SameSite cookie 같은 웹 보안 메커니즘이 의도대로 동작한다.
요약
file://는 origin 개념을 깨므로 커스텀 protocol로 대체.registerSchemesAsPrivileged는 반드시app.ready전.- handler는 디렉토리 escape 방어가 필수.
secure: true를 주어야 Service Worker·Web Crypto가 동작한다.