CSP란

  • Content Security Policy. 내 페이지가 어디서 온 코드·자원만 로드·실행할지를 정하는 화이트리스트
  • 주 목적은 XSS 방어 — 공격자가 스크립트를 주입해도, 허용된 출처가 아니면 브라우저가 실행을 거부한다

CORS와 다르다 (방향)

통제 대상방향
CORS내 응답을 누가 읽을 수 있나밖 → 나
CSP내 페이지가 뭘 로드·실행할 수 있나나 → 밖

어떻게 거나

HTTP 응답 헤더(또는 <meta> 태그)로 내린다.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com;
  img-src 'self' data:;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';

주요 디렉티브

디렉티브통제 대상
default-src지정 안 한 디렉티브의 기본값
script-srcJS 로드·실행 출처
style-srcCSS
img-src이미지
connect-srcfetch·XHR·WebSocket·sendBeacon
frame-srciframe에 넣을 출처
frame-ancestors내 페이지를 iframe에 넣을 수 있는 주체 (클릭재킹 방어)

값으로 쓰는 것:

  • 'self' — 내 출처만 / 'none' — 전부 금지
  • https://cdn.example.com — 특정 출처
  • 'unsafe-inline', 'unsafe-eval' — 인라인·eval 허용 (보안 약화)
  • nonce-xxx, sha256-... — 특정 인라인 스크립트만 콕 집어 허용

적용은 Report-Only부터

  • 처음부터 enforce로 켜면 인라인·서드파티·API 호출이 여기저기 깨진다
  • Content-Security-Policy-Report-Only차단 없이 위반만 리포트한다
  • 순서: Report-Only로 관찰 → allowlist 보강 → enforce 전환. 배포 후에도 버전 필터로 재검증한다

함정

enforce로 전환한 뒤 실제로 밟게 되는 것들. 핵심은 한 번에 안 끝난다 — 코드·공식 문서만으로 만든 allowlist는 실측으로 보강해야 한다.

와일드카드가 형제 도메인을 못 덮는다

  • *.b.ca.b.c만 덮고 x.c는 못 덮는다
  • 이름이 비슷한 형제 도메인(스크립트용·비콘용이 갈리는 경우)은 각각 명시한다

SDK는 로드 체인이다

  • 태그 하나를 열면 그 태그가 부르는 후속 script·비콘(img+connect)·iframe(frame)이 반드시 따라온다
  • 1단계를 허용한 뒤 재실측해서 다음 단계를 추가한다

같은 벤더 도메인은 세트로 다닌다

  • 한 기능이 여러 호스트를 함께 쓴다(예: 이벤트 수집 + ID 매칭)
  • 디렉티브마다 세트로 움직이므로, 하나만 관측돼도 세트로 넣을지 검토한다

비콘은 전송 채널이 런타임에 바뀐다

  • 같은 픽셀이 세션마다 이미지 비콘 ↔ fetch ↔ sendBeacon을 오간다
  • 비콘 도메인은 img-src·connect-src 양쪽에 (필요하면 frame까지) 연다
방식CSP 소관
이미지 픽셀 (new Image().src = ...)img-src
fetch / XHRconnect-src
navigator.sendBeacon() (이탈 순간에도 전송 보장)connect-src

허용했는데도 차단된다 → 302 리다이렉트를 의심 ⭐

  • CSP는 리다이렉트 목적지도 같은 디렉티브로 검사한다
  • 그런데 위반 리포트의 blocked-uri에는 목적지 노출 방지로 원래 URL만 찍혀 원인이 가려진다
  • 목록에 있는 도메인인데 계속 막히면 curl -I로 302 여부를 확인하고 도착지를 목록에 추가한다

정체불명 호스트는 출처부터 확정

  • 랜덤 문자열 호스트라고 로테이팅이 아니다 — config에 박힌 고정값일 수 있다
  • 페이지가 로드한 SDK·config를 grep해 출처를 확정한 뒤 정확한 호스트만 핀 고정한다
  • 클라우드 인프라 와일드카드(*.on.aws, *.run.app 등)는 열지 않는다

와일드카드로 열지, 개별 핀 할지

세 가지가 모두 예면 와일드카드, 아니면 개별 핀:

  1. 같은 소유자의 CDN인가
  2. img-src 같은 저위험 디렉티브인가
  3. 변형(지역 샤드·용도별 서브도메인)이 구조적으로 계속 생기는가

런타임 인라인 주입은 해시·nonce와 충돌한다

  • 런타임에 인라인 스크립트를 주입하는 도구는 빌드타임 sha256 해시로 못 잡는다
  • 스펙상 해시가 존재하면 'unsafe-inline'이 무시되므로 둘을 같이 쓸 수도 없다
  • 하드닝하려면 그런 주입 태그를 먼저 정리해야 한다

허용이 답이 아닌 위반

모든 위반이 allowlist 대상은 아니다. 아래는 차단이 정상이다.

위반 종류정답
외부 확장 프로그램 주입 (소스에 없는 도메인)열지 않는다. 코드 전체 grep으로 부재 확인
봇이 낸 위반 (크롤러가 http로 렌더)수집 단계에서 drop
데이터 오염 (본문에 잘못된 URL 저장)도메인 대신 데이터를 고친다
브라우저·웹뷰 주입 (번역 위젯, 웹뷰 폰트)"차단 시 무엇이 깨지나" UX 기준으로 판단

진단 팁

  • 위반 리포트의 sourceFile이 진짜 발신자가 아닐 수 있다 — 모니터링 도구가 fetch를 래핑하면 그 번들로 찍힌다
  • 브라우저에서 쌓인 위반을 직접 보려면 ReportingObserver:
new ReportingObserver(
  (reports) =>
    reports.forEach((r) => console.log(r.body.blockedURL, r.body.effectiveDirective)),
  { types: ["csp-violation"], buffered: true },
).observe();
  • HTML에 Cache-Control이 없으면 구버전 정책이 웹뷰·CDN에 오래 남아 위반 잔상이 생긴다

정리

  • CSP는 페이지가 로드·실행할 자원의 출처를 제한해 XSS를 막는다. CORS가 "남이 내 데이터를 읽는 것"을 통제한다면, CSP는 "내 페이지가 뭘 불러오는지"를 통제한다
  • 적용은 켜는 게 끝이 아니라 "Report-Only → 실측 → allowlist 보강 → enforce" 루프다
  • 제일 자주 발목 잡는 두 가지: 리다이렉트로 숨는 차단(302)허용하면 안 되는 위반을 구분하는 것