CSP를 사이트 안 깨뜨리고 도입하는 법 — Report-Only에서 강제 모드까지 3단계
Content-Security-Policy(CSP)는 도입 효과가 확실한 보안 헤더지만, 실무에서 가장 많이 나오는 실패 사례는 "정책을 잘못 짜서 사이트가 죽는" 경우입니다. 서드파티 위젯 하나, 인라인 스타일 하나만 놓쳐도 그 리소스가 통째로 차단됩니다. 그래서 CSP는 강제 모드를 바로 켜는 게 아니라, 위반만 관찰하는 단계를 거쳐 점진적으로 좁혀나가는 것이 표준적인 접근입니다.
1. 왜 강제 모드부터 켜면 위험한가
Content-Security-Policy 헤더는 정책에 어긋나는 리소스 로드를 즉시 차단합니다. 문제는 실제 프로덕션 사이트가 어떤 스크립트·스타일·이미지·폰트를 어디서 불러오는지 개발자 본인도 완전히 파악하지 못하는 경우가 많다는 점입니다. 광고 스크립트가 또 다른 도메인에서 서브 리소스를 동적으로 불러오거나, CMS 에디터가 인라인 style 속성을 자동 삽입하는 식으로 눈에 안 띄는 의존성이 숨어 있습니다. 정책을 처음부터 강제로 적용하면 이런 숨은 의존성이 걸릴 때마다 화면이 깨지거나 기능이 조용히 멈추는데, 사용자가 먼저 발견하고 신고하는 최악의 순서로 문제가 드러납니다.
2. 1단계: Report-Only로 관찰만 한다
이 문제를 피하는 표준 방법이 Content-Security-Policy-Report-Only 헤더입니다. 이름 그대로 "Report-Only"이며, 정책을 위반해도 리소스를 차단하지 않고 위반 사실만 리포트로 수집합니다. 브라우저가 위반을 감지하면 정책에 지정한 report-uri(구형) 또는 report-to(신형) 엔드포인트로 JSON 위반 보고서를 전송합니다.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://cdn.example.com; report-uri /csp-report이 상태에서는 정책에 없는 도메인에서 스크립트를 불러와도 실제로는 로드되고 실행됩니다 — 다만 위반 보고서가
/csp-report로 날아갑니다. 사이트는 평소와 똑같이 동작하면서 "만약 이 정책을 강제로 켰다면 무엇이 깨졌을지"를 미리 관찰할 수 있는 단계입니다.
이 단계에서 CSP 검증기로 초안 정책을 미리 파싱해 보면, 지시어 오타나 default-src 누락처럼 리포트가 쌓이기 전에 걸러낼 수 있는 구조적 문제를 먼저 잡을 수 있습니다.
3. 2단계: 리포트를 분석해 정책을 다듬는다
Report-Only를 며칠~몇 주 운영하면 실제 트래픽에서 어떤 리소스가 정책과 충돌하는지 위반 보고서로 쌓입니다. 이 리포트를 검토하면서 두 갈래로 나눠 처리합니다.
- 정당한 리소스인데 정책이 좁았던 경우: 해당 도메인을
script-src/style-src등 알맞은 지시어의 allowlist에 추가합니다. - 애초에 있으면 안 되는 리소스인 경우(악성 삽입, 방치된 레거시 스크립트 등): 정책을 그대로 두고, 실제 코드에서 해당 리소스 로드 자체를 제거합니다.
이 단계를 건너뛰고 바로 강제 모드로 넘어가면, 위에서 갈라야 할 두 갈래를 사이트가 실제로 깨진 뒤에야 사후적으로 파악하게 됩니다. Report-Only 단계의 존재 이유가 바로 이 "사전 파악"입니다.
4. 3단계: 강제 모드로 전환한다
리포트가 잠잠해지고 정책이 실제 트래픽 패턴과 맞아떨어졌다고 판단되면, 같은 정책 문자열을 헤더 이름만 바꿔서 Content-Security-Policy로 전환합니다. 이 시점부터 정책 위반은 실제로 차단됩니다. 전환 직후에도 report-uri는 유지하는 것이 좋습니다 — 강제 모드에서도 위반 보고서는 계속 오며, 이후 새로 추가되는 기능이 정책을 깨뜨릴 때 조기에 알아챌 수 있는 감시 채널로 계속 활용할 수 있기 때문입니다.
| 단계 | 헤더 | 동작 | 목적 |
|---|---|---|---|
| 1 | Content-Security-Policy-Report-Only | 차단 안 함, 리포트만 전송 | 영향 범위 관찰 |
| 2 | (동일, 반복 조정) | 차단 안 함 | 리포트 기반 정책 보정 |
| 3 | Content-Security-Policy | 위반 시 실제 차단 | 강제 적용, 리포트는 계속 수집 |
5. data: URI는 지시어에 따라 위험도가 완전히 다르다
정책을 다듬는 과정에서 자주 마주치는 판단이 data: URI 허용 여부입니다. 같은 data: 스킴이라도 어느 지시어에 넣느냐에 따라 위험도가 크게 갈립니다.
- img-src의 data:: base64로 인코딩된 이미지를 인라인으로 표시하는 용도로, 실행 가능한 코드가 아니라 비교적 안전합니다. 아이콘 스프라이트나 작은 썸네일을 인라인화할 때 흔히 씁니다.
- script-src / style-src의 data:: 공격자가
<script src="data:text/javascript,...">형태로 인라인 스크립트 차단(unsafe-inline미허용)을 우회해 임의 코드를 실행시킬 수 있는 경로가 됩니다. 즉 애써unsafe-inline을 빼서 XSS를 막아 놨더라도, script-src에 data:를 열어두면 사실상 같은 구멍이 다른 문법으로 다시 열립니다.
CSP 검증기에 정책을 붙여넣으면 unsafe-inline·unsafe-eval·와일드카드(*) 사용을 지시어 구분 없이 균일하게 경고해 주지만, "이 지시어의 data:는 안전하고 저 지시어의 data:는 위험하다"는 지시어별 문맥 판단까지 자동으로 매겨주지는 않습니다. 정책 초안을 만들 때는 도구의 경고 목록을 1차 필터로 쓰고, data: URI가 어느 지시어에 들어가는지는 위 기준으로 직접 확인하는 것이 안전합니다.
자주 묻는 질문
Q. Report-Only 헤더만 켜두고 강제 모드로 절대 안 넘어가도 되나요?
보안 효과가 없습니다. Report-Only는 관찰용일 뿐 실제로 아무것도 차단하지 않으므로, 최종적으로 Content-Security-Policy로 전환해야 CSP가 실제 방어 역할을 합니다.
Q. Report-Only와 강제 모드 헤더를 동시에 보낼 수 있나요?
네, 두 헤더를 동시에 전송할 수 있습니다. 이미 안정화된 엄격한 정책은 Content-Security-Policy로 강제하면서, 다음 단계로 좁히려는 더 엄격한 실험 정책은 Report-Only로 별도 관찰하는 식으로 병행하는 경우가 흔합니다.
Q. report-uri와 report-to 중 뭘 써야 하나요?
report-uri는 구형이지만 브라우저 호환성이 넓고, report-to는 최신 Reporting API 기반으로 더 유연하지만 별도의 Report-To 헤더 설정이 필요합니다. 호환성이 우선이면 report-uri를 두고, 최신 스택이면 두 개를 함께 지정해 폴백을 확보하는 방식이 일반적입니다.
Q. 정책 초안은 어떻게 검증하나요?
CSP 검증기에 헤더 값을 붙여넣으면 지시어별로 파싱해 unsafe 키워드, 와일드카드, default-src 누락 여부를 진단해 줍니다. Report-Only로 배포하기 전 구조적인 오타나 누락을 먼저 걸러내는 용도로 쓸 수 있습니다.