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-src | JS 로드·실행 출처 |
style-src | CSS |
img-src | 이미지 |
connect-src | fetch·XHR·WebSocket·sendBeacon |
frame-src | iframe에 넣을 출처 |
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.c는a.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 / XHR | connect-src |
navigator.sendBeacon() (이탈 순간에도 전송 보장) | connect-src |
허용했는데도 차단된다 → 302 리다이렉트를 의심 ⭐
- CSP는 리다이렉트 목적지도 같은 디렉티브로 검사한다
- 그런데 위반 리포트의
blocked-uri에는 목적지 노출 방지로 원래 URL만 찍혀 원인이 가려진다 - 목록에 있는 도메인인데 계속 막히면
curl -I로 302 여부를 확인하고 도착지를 목록에 추가한다
정체불명 호스트는 출처부터 확정
- 랜덤 문자열 호스트라고 로테이팅이 아니다 — config에 박힌 고정값일 수 있다
- 페이지가 로드한 SDK·config를 grep해 출처를 확정한 뒤 정확한 호스트만 핀 고정한다
- 클라우드 인프라 와일드카드(
*.on.aws,*.run.app등)는 열지 않는다
와일드카드로 열지, 개별 핀 할지
세 가지가 모두 예면 와일드카드, 아니면 개별 핀:
- 같은 소유자의 CDN인가
img-src같은 저위험 디렉티브인가- 변형(지역 샤드·용도별 서브도메인)이 구조적으로 계속 생기는가
런타임 인라인 주입은 해시·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) 과 허용하면 안 되는 위반을 구분하는 것