본문으로 건너뛰기
yutils

Content Security Policy — XSS 차단을 위한 CSP 헤더 정복

CSP 가 XSS 를 어떻게 막는지, 실제로 필요한 디렉티브, nonce / hash / strict-dynamic 선택, report-only 점진 도입, AdSense/GA 호환성.

약 9분 읽기

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-srcJS 출처'self' 'nonce-XYZ' 'strict-dynamic'
style-srcCSS 출처'self' 'unsafe-inline'
img-src이미지'self' data: https:
connect-srcfetch/XHR/WebSocket'self' https://api.example.com
font-src폰트'self' https://fonts.gstatic.com
frame-srciframe 안 들어갈 페이지https://www.youtube.com
frame-ancestors이 페이지를 누가 iframe 으로 박을 수 있나 (clickjacking 방어)'none' 또는 'self'
form-actionform submit 대상'self'
base-uri<base> 태그 허용 값'self' 또는 'none'
upgrade-insecure-requestshttp → 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:

흐름:

  1. nonce 박힌 첫 스크립트만 직접 허용.
  2. 그 스크립트가 createElement("script") 로 로드하는 자식은 strict-dynamic 으로 자동 허용.
  3. 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: DENYframe-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 default

DOM-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 동시 방어.
가이드 목록으로