⚡ Electron5. 보안 모델05 — CSP & webRequest

05 — CSP & webRequest

한 줄 답: 격리(contextIsolation·sandbox)와 노출 최소화(nodeIntegration: false)가 공격 성공 후의 방어라면, CSP는 공격 자체를 막는다 — 인라인 스크립트 차단, 외부 origin 스크립트 차단, eval 차단. Electron에서는 meta 태그가 아니라 session.webRequest.onHeadersReceived모든 응답에 강제 주입하는 것이 사실상 표준. 그리고 webSecurity: false어떤 상황에서도 켜지 않는다 — same-origin policy 자체를 무력화한다.


웹에서는 ~인데 Electron에서는 ~

웹에서는 CSP를 서버가 Content-Security-Policy 헤더로 응답에 넣어주고, 브라우저가 enforce한다. meta 태그도 보조 수단으로 가능. Electron에서는 로컬 file:// 페이지가 많아 서버가 없는 경우가 흔하다. 그래서 meta 태그를 쓰거나 session.webRequest로 모든 응답을 가로채 헤더를 주입한다. 후자가 강력 — 외부 origin 응답에도 우리 정책을 덮어쓸 수 있다.


Why — XSS 자체를 막는 마지막 보루

격리·sandbox·노출 최소화가 모두 공격이 성공한 후피해 최소화다. 하지만 공격을 처음부터 막을 수 있다면 더 좋다 — CSP가 그 자리다.

격리·sandboxCSP
XSS 성공 후 RCE까지 가지 않게XSS 자체를 못 일어나게
require 못 함<script>alert(1)</script> 실행 자체 차단
OS syscall 차단외부 도메인 스크립트 로드 차단
다층 방어의 가장 안쪽다층 방어의 가장 바깥쪽

이상적으로는 둘 다. CSP만 믿으면 — CSP가 우회되는 zero-day(unsafe-eval 부재 시 trampolining 등)에서 무너진다. 격리만 믿으면 — XSS가 너무 흔해서 방어 비용이 너무 자주 발생한다.

또 Electron에서 CSP는 외부 origin을 다룰 때 특히 중요하다. 우리 앱이 외부 페이지를 iframe·webview로 임베드하면, 그 페이지의 CSP가 약하면 우리 앱 권한까지 누설될 수 있다. webRequest로 우리 정책을 강제 덮어쓰는 패턴이 사실상 표준.


How — CSP를 어떻게 주입하나

3가지 주입 방법

방법 1 — meta 태그 (가장 간단, 부분 방어)

{/* index.html */}
<!doctype html>
<html>
<head>
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.myapp.com">
</head>

장점: 코드 한 줄 없이 적용. 단점: 그 HTML 페이지에만 적용. 새 창·iframe·외부 페이지는 별도 정책 필요.

방법 2 — webRequest로 모든 응답에 헤더 강제 (Electron 표준)

// main.js
const { app, BrowserWindow, session } = require('electron');
 
app.whenReady().then(() => {
  // 디폴트 세션의 모든 응답에 CSP 헤더 주입
  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
    callback({
      responseHeaders: {
        ...details.responseHeaders,
        'Content-Security-Policy': [
          "default-src 'self'; " +
          "script-src 'self'; " +
          "style-src 'self' 'unsafe-inline'; " +
          "img-src 'self' data: https:; " +
          "connect-src 'self' https://api.myapp.com; " +
          "frame-ancestors 'none'; " +
          "object-src 'none'; " +
          "base-uri 'self'"
        ],
      },
    });
  });
 
  const win = new BrowserWindow({ /* ... */ });
  win.loadFile('index.html');
});

이 패턴이 Electron 보안 가이드의 권장 표준. 모든 응답에 우리 정책이 덮어씌워진다. 외부 origin이 자체 CSP를 보내더라도 우리가 더 강한 정책으로 교체할 수 있다.

방법 3 — setHeader로 응답 헤더 조작 (Express 등에서 익숙한 패턴)

원격 서버를 직접 통제하면 거기서 원본 응답 헤더를 잘 보낼 수 있다. 하이브리드 앱(remote URL + 로컬 file:// 혼합)에서는 방법 2와 함께 쓰는 게 좋다.

권장 CSP 정책 (그대로 복사 가능)

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' data:;
  connect-src 'self' https://api.myapp.com wss://ws.myapp.com;
  media-src 'self' blob:;
  object-src 'none';
  frame-src 'none';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';

요지:

  • default-src 'self' — 디폴트는 같은 origin만.
  • script-src 'self' 없이 unsafe-inline — 인라인 스크립트 차단 (XSS 방어의 핵심).
  • style-src 'unsafe-inline' — CSS-in-JS·styled-components를 쓰면 필요. script에는 절대 unsafe-inline을 두지 말 것.
  • object-src 'none'<object>·<embed> 차단 (Flash 등 legacy).
  • frame-ancestors 'none' — 우리 앱이 다른 앱의 iframe이 되지 않게.
  • base-uri 'self'<base> 태그 hijack 방어.

React 사용 시 — unsafe-inline 제거 패턴

React가 inline <style>이나 React DevTools를 위해 unsafe-inline을 요구하는 경우가 있다. nonce 또는 hash 기반 CSP가 답.

// main.js — 매 응답마다 nonce 생성
const crypto = require('crypto');
 
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  // 응답 본문에도 nonce 주입해야 함 (HTML rewriting)
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Content-Security-Policy': [
        `default-src 'self'; script-src 'self' 'nonce-${nonce}'; ...`
      ],
    },
  });
});

'nonce-...' 또는 'sha256-...'특정 인라인 스크립트만 화이트리스트. 이 방식이 완벽한 CSP의 표준.


What — webRequest API와 webSecurity

session.webRequest 이벤트 라이프사이클

각 hook으로 요청·응답을 가로채 정책을 강제할 수 있다.

// 외부 트래커 도메인 차단 예
session.defaultSession.webRequest.onBeforeRequest(
  { urls: ['https://tracker.evil.com/*'] },
  (details, callback) => callback({ cancel: true })
);
 
// 모든 요청에 인증 헤더 추가
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
  callback({
    requestHeaders: {
      ...details.requestHeaders,
      'X-App-Token': process.env.APP_TOKEN,
    },
  });
});
 
// 모든 응답에 보안 헤더 강제
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Content-Security-Policy': ["default-src 'self'; ..."],
      'X-Content-Type-Options': ['nosniff'],
      'X-Frame-Options': ['DENY'],
      'Referrer-Policy': ['strict-origin-when-cross-origin'],
      'Strict-Transport-Security': ['max-age=63072000; includeSubDomains; preload'],
    },
  });
});

webSecurity: false어떤 경우에도 켜지 않는다

// ❌❌❌ 절대 금기
new BrowserWindow({
  webPreferences: {
    webSecurity: false,   // ← 이 한 줄이 same-origin policy 무력화
  },
});

켜는 순간:

  • CORS 무력화 — 임의 origin이 우리 origin의 데이터에 fetch 가능.
  • file:// 페이지가 임의 https origin에 접근 가능.
  • mixed content (HTTPS 페이지에서 HTTP 자원 로드) 경고 무시.

흔한 변명 “개발 편의를 위해”Electron 공식 보안 가이드 1번 항목이 *“Only load secure content”*이고 그 두 번째 권고가 webSecurity 절대 끄지 말기다. CORS 문제는 백엔드 CORS 헤더 또는 프록시로 풀 일.

마찬가지 금기:

  • allowRunningInsecureContent: true — HTTPS 페이지에서 HTTP 자원 허용
  • experimentalFeatures: true — Chromium 실험 기능 활성
  • enableBlinkFeatures: '...' — 비표준 Blink 기능 활성

이 셋이 옛 boilerplate에 가끔 명시적으로 true로 적혀 있다. 모두 제거.

CSP 검증 도구

도구하는 일
Chrome DevTools Security 탭페이지의 CSP 상태 표시
csp-evaluator.withgoogle.comCSP 문자열 입력 → 위험도 평가
electronegativitywebPreferences·meta 태그 CSP 정적 분석
브라우저 콘솔 위반 로그CSP 위반 시 자동 출력

CSP 보고 모드 (배포 전에 실험)

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

Report-Only실제 차단은 안 하고 위반만 보고한다. 정책 도입 단계에서 기존 코드가 어떤 위반을 일으키는지 먼저 파악한 뒤 enforce로 전환.


What-if — CSP가 약하거나 webSecurity가 꺼져 있으면

1) script-src 'unsafe-inline'을 켜두면

<script>alert(1)</script> 인라인 XSS가 작동한다. CSP 전체가 XSS 방어로서 무력화된다. 대응: nonce 또는 hash 기반으로 필요한 인라인 한 줄씩 화이트리스트. 또는 모든 JS를 별도 파일로 분리.

2) script-src 'unsafe-eval'을 켜두면

eval('...'), new Function('...')이 작동. 많은 번들러가 dev 모드에 eval을 쓰지만 production 빌드는 없애야 한다. 대응: webpack devtool: 'source-map' (eval-source-map 아닌 것), vite build.sourcemap: true.

3) default-src *를 깔아두면

어떤 origin이든 허용. CSP의 모든 의미가 사라진다. 대응: default-src 'self'로 시작해서 필요한 도메인만 화이트리스트.

4) webSecurity: false로 CORS 우회

→ same-origin policy가 완전히 무력화. 임의 origin 페이지가 localhost 파일에 fetch 가능 — 공격자 서버가 우리 로컬 파일을 읽어간다. 대응: 백엔드에 제대로 된 CORS 헤더 + webSecurity: true 유지.

5) frame-ancestors를 안 깔면

→ 우리 앱이 공격자 사이트의 iframe이 되어 clickjacking 가능 (UI 위에 투명 iframe). 대응: frame-ancestors 'none' 또는 X-Frame-Options: DENY.


Insight — 흥미로운 이야기

CVE-2020-15174 (WhatsApp Desktop)는 mailto: 링크 미리보기에서 XSS가 가능했다. 그 XSS가 RCE까지 간 이유nodeIntegration이 켜진 채였기 때문. 만약 strict CSP(script-src 'self')가 깔려 있었다면 — XSS 자체가 실행되지 못해서 RCE 경로가 시작도 못 했다.

→ 교훈: CSP는 가장 바깥 방어인데, 그게 깔려 있었다면 나머지 옵션의 실수RCE로 가지 않을 수 있다.

”Slack Desktop의 CSP는 3년에 걸쳐 강화됐다”

Slack의 공개 보안 블로그 글에 따르면 — 초기 Slack Desktop의 CSP는 *default-src **에 가까웠다 (외부 임베드·oembed·trusted partner 등 도메인이 너무 많았기 때문). 2018년부터 Report-Only 모드3년간 위반을 수집하고, 그 데이터로 허용 도메인을 정밀 화이트리스트했다. 결과적으로 수십 개 도메인의 명시 리스트로 좁혀졌고, 그 사이에 2건의 XSS 시도가 CSP에 자동 차단됐다.

→ 교훈: CSP 도입은 Report-Only로 시작 + 위반 로그를 데이터로 + 점진적 강화. 한 번에 strict로 가지 않는다.

”webSecurity: false를 켰던 회사의 사고”

2019년 한 핀테크 스타트업 데스크톱 앱이 *webSecurity: false*로 출시됐다 — 사내 API의 CORS 문제를 우회하기 위해서. 그 결과 사용자 PC가 공격자 사이트 방문 시 — 그 사이트의 JS가 Electron 앱 안의 로컬 토큰 파일을 fetch해 갔다. 사고 후 회사는 CORS를 백엔드에 제대로 깔고 webSecurity: true로 전환. 사고 보고서의 한 줄 — webSecurity: false는 한 번도 안전한 적이 없었다.”

→ 교훈: 보안 옵션을 편의 위해 끄는 결정반드시 사고로 이어진다. CORS 문제는 CORS로 풀어야지 same-origin policy를 끄지 않는다.


요약 + 다이어그램

CSP는 XSS 자체를 막는 마지막 보루다. Electron에서는 session.webRequest.onHeadersReceived모든 응답에 CSP 헤더를 강제 주입하는 것이 사실상 표준. script-src 'self'로 인라인·외부 스크립트를 차단하고, nonce/hash로 필요한 인라인만 화이트리스트. webSecurity: false어떤 상황에도 켜지 않는다 — same-origin policy 무력화의 비용은 항상 CORS 회피의 편의보다 크다.

다음 문서: 06-permissions-and-navigation.mdnavigation과 권한을 통제하는 6번째 층. will-navigate·setWindowOpenHandler·setPermissionRequestHandler로 외부 URL과 OS 권한 prompt를 통제한다.