⚡ Electron4. 네이티브 통합커스텀 protocol & scheme

커스텀 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 nullXHR 못 함, CSP 우회됨
HTTP 서버 띄우기localhost:0 (랜덤 포트)포트 충돌·다른 앱이 접근 가능
커스텀 schemeprotocol.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) 표

권한의미
standardURL 파싱이 http처럼 동작 (path/query 인식)
securesecure context 부여 (Service Worker, crypto.subtle 가능)
supportFetchAPIfetch('app://...') 허용
corsEnabledCORS 검사 활성화
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가 동작한다.