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가 그 자리다.
| 격리·sandbox | CSP |
|---|---|
| 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.com | CSP 문자열 입력 → 위험도 평가 |
electronegativity | webPreferences·meta 태그 CSP 정적 분석 |
| 브라우저 콘솔 위반 로그 | CSP 위반 시 자동 출력 |
CSP 보고 모드 (배포 전에 실험)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportReport-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 — 흥미로운 이야기
”WhatsApp Desktop 2020 — mailto: link XSS이 CSP에 막혔다면”
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.md— navigation과 권한을 통제하는 6번째 층.will-navigate·setWindowOpenHandler·setPermissionRequestHandler로 외부 URL과 OS 권한 prompt를 통제한다.