XSS (Cross-Site Scripting) 가 OWASP Top 10 에 20 년째 머무는 이유는 단 하나 — 입력 검증을 100% 보장하기 어렵기 때문이다. 어딘가에서 한 줄 새면 끝. Content Security Policy (CSP) 는 마지막 방어선 — 설사 악성 스크립트가 페이지에 박혀도 브라우저가 실행 안 하게 만든다. 이 가이드는 CSP 가 실제로 어떻게 동작하는지, 어떤 디렉티브를 박아야 하는지, AdSense/GA 같은 외부 스크립트와 어떻게 공존 하는지 정리한다.
CSP 가 막는 것
브라우저는 페이지가 로드하려는 모든 자원 (스크립트·스타일·이미지·폰트· iframe·fetch·media) 의 출처를 CSP 와 대조. 허용 안 된 출처면 차단 + DevTools 콘솔에 에러.
- 인라인 스크립트:
<script>alert(1)</script>— 기본적으로 차단. - 인라인 이벤트 핸들러:
<a onclick="...">— 차단. - 외부 스크립트:
<script src="evil.com/x.js">— script-src 허용 목록에 없으면 차단. - eval / Function — 기본 차단.
unsafe-eval이 있어야 허용.
실제 XSS 시나리오: 사용자 입력 페이지에 <script> 삽입 성공 → 브라우저가 CSP 위반으로 거부 → 공격 무효화.
헤더 형식
Content-Security-Policy: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.example.com; frame-ancestors 'none'디렉티브 + 허용 출처 목록. ; 로 구분. default-src 가 fallback — 다른 디렉티브 없으면 이걸 사용.
주요 디렉티브
| 디렉티브 | 제어 대상 | 전형적 값 |
|---|---|---|
default-src | 다른 *-src 의 fallback | 'self' |
script-src | JS 출처 | 'self' 'nonce-XYZ' 'strict-dynamic' |
style-src | CSS 출처 | 'self' 'unsafe-inline' |
img-src | 이미지 | 'self' data: https: |
connect-src | fetch/XHR/WebSocket | 'self' https://api.example.com |
font-src | 폰트 | 'self' https://fonts.gstatic.com |
frame-src | iframe 안 들어갈 페이지 | https://www.youtube.com |
frame-ancestors | 이 페이지를 누가 iframe 으로 박을 수 있나 (clickjacking 방어) | 'none' 또는 'self' |
form-action | form submit 대상 | 'self' |
base-uri | <base> 태그 허용 값 | 'self' 또는 'none' |
upgrade-insecure-requests | http → https 자동 | (값 없음) |
인라인 스크립트 — 세 가지 처리
1. nonce (권장)
매 요청마다 랜덤 nonce 생성, 헤더와 <script nonce> 양쪽에 박는다. 공격자가 페이지에 새 <script> 삽입해도 nonce 모르면 실행 X.
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m123'
<script nonce="r4nd0m123">
// 이 코드만 실행됨
</script>nonce 는 16 바이트+ 보안 랜덤. 매 요청 새로. 정적 사이트에서는 빌드 타임 어렵고 — Next.js·SSR 같은 동적 페이지에서 가장 자연스럽다.
2. hash
스크립트 내용의 SHA-256 해시를 헤더에 박는다. 같은 내용이면 통과.
Content-Security-Policy: script-src 'sha256-AbCdEf...='
<script>
console.log("hello");
</script>장점: 정적 사이트 친화. 단점: 내용 변경 시 해시도 변경 — 빌드 파이프라 인에서 자동 계산 필요.
3. unsafe-inline (비추)
모든 인라인 스크립트 허용 — CSP 의 핵심 효과 거의 무력화. legacy 환경 외에는 사용 X. style 에는 'unsafe-inline' 종종 허용 (XSS 영향 적음), script 에는 절대 X.
strict-dynamic — 외부 라이브러리와 공존
nonce 가 박힌 스크립트가 로드한 자식 스크립트를 자동 허용. 외부 라이브 러리 (Google Analytics, Stripe.js 등) 가 자체 동적 로드 할 때 유용.
Content-Security-Policy: script-src 'nonce-r4nd0m' 'strict-dynamic' https:흐름:
- nonce 박힌 첫 스크립트만 직접 허용.
- 그 스크립트가
createElement("script")로 로드하는 자식은 strict-dynamic 으로 자동 허용. - http URL 화이트리스트는 무시 —
https:는 strict-dynamic 미지원 브라우저 fallback.
Google CSP Evaluator 가 권장하는 패턴. URL 파서 로 외부 라이브러리 URL 의 host 를 분해해 화이트리스트 박을 때 활용.
점진 도입 — Report-Only
실제 차단 없이 위반 사항만 보고받기:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report헤더 이름이 Content-Security-Policy-Report-Only. 위반 시 차단 X 하고 JSON 으로 /csp-report 에 POST. 로그 보고 CSP 조정 → 충분히 안전해지면 정식 Content-Security-Policy 로 전환.
report-uri 는 deprecated. 새 형식은 report-to + Reporting API. 한동안 둘 다 박는 게 안전.
AdSense / Google Analytics 와 공존
AdSense 와 GA 가 동작하려면 추가 허용 필요:
script-src 'self' 'nonce-XYZ' 'strict-dynamic'
https://pagead2.googlesyndication.com
https://www.googletagmanager.com
https://www.google-analytics.com;
img-src 'self' data: https:;
connect-src 'self' https://www.google-analytics.com
https://pagead2.googlesyndication.com;
frame-src https://googleads.g.doubleclick.net
https://tpc.googlesyndication.com;img-src https: 와일드카드는 AdSense 가 광고 이미지를 어디 에서든 가져올 수 있게. 광고 도메인은 시간에 따라 추가될 수 있어 일부 는 와일드카드가 현실적 (트레이드오프 — XSS 페이로드를 이미지로 위장하는 방어 약화).
흔한 함정
1. unsafe-inline 으로 시작
"에러 무서워서" 다 허용. CSP 가 사실상 없음. nonce 또는 hash 로 옮기는 게 정답.
2. script-src 'unsafe-eval'
webpack dev mode, 일부 템플릿 엔진이 eval 사용. 프로덕션에서는 절대 켜지 말 것 — eval 이 공격 표면 확대.
3. nonce 를 정적 값으로
매 요청마다 새 nonce 생성이 핵심. 같은 nonce 가 영구적이면 공격자가 한 번 알아내고 무한 사용.
4. 와일드카드 남용
script-src * = CSP 없음과 동일. 와일드카드는 디렉티브별 필요 최소만.
5. X-Frame-Options 와 혼동
X-Frame-Options: DENY 와 frame-ancestors 'none' 는 같은 효과 (clickjacking 방어). CSP 가 표준 후속이므로 둘 다 박아도 OK. 모던 브라우저는 CSP 우선.
6. localhost 개발 환경 미고려
http://localhost:3000 의 HMR (Vite, Next.js dev) 은 인라인 스크립트 + ws://. 개발 모드에 별도 완화된 CSP, 프로덕션에 strict.
7. report-uri 만 박고 시작
Report-Only 헤더 없이 위반이 차단되면 사이트 깨짐. 먼저 Report- Only 로 점진 도입, 위반 0 확인 후 enforce.
Trusted Types — 다음 단계
Chrome 83+ 의 추가 layer. innerHTML 같은 sink 에 raw 문자열 대신 검증된 객체만 허용.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types defaultDOM-based XSS 마지막 방어선. Safari/Firefox 아직 X 지만 Chrome 점유율 만으로도 의미. AdSense 등 외부 스크립트와 호환성 검증 필요.
실제 시작 템플릿
간단한 사이트 + GA + 외부 폰트:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-RANDOM' 'strict-dynamic' https:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://www.google-analytics.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-to csp-endpoint;
report-uri /csp-report;헤더 응답 확인은 HTTP 상태 코드 에서 도구 설명 + 응답 헤더 의미 빠르게 찾기 가능. CSP 검증은 Google 의 CSP Evaluator (csp-evaluator.withgoogle.com) 가 표준.
요약
- CSP = 페이지가 로드할 자원의 출처 허용 목록. XSS 마지막 방어선.
- 핵심 디렉티브: default-src / script-src / style-src / img-src / connect-src / frame-ancestors.
- script 인라인은 nonce (권장) / hash.
'unsafe-inline'피하 기. - 외부 라이브러리는 nonce + strict-dynamic 패턴.
- 도입 시 Report-Only 로 시작 → 위반 0 확인 후 enforce.
- frame-ancestors 'none' 으로 clickjacking 동시 방어.