보안 리뷰가 책상 위에 떨어지는 날의 전개는 보통 똑같다. 누군가 프로덕션에 Mozilla Observatory나 Lighthouse를 돌린다. 보고서는 Content-Security-Policy 헤더가 빠졌다고 표시한다. MDN을 열어 30개 남짓한 지시문을 훑어보고 “그럴듯한” 정책을 작성해 배포한다. 이틀 뒤 에러 트래커는 망가진 stripe.js, 로드 안 되는 Google Fonts로 가득하고 Sentry 인박스는 끝없이 스크롤된다. 정책을 롤백한다. 6개월 뒤 같은 일이 반복된다.
CSP는 크로스사이트 스크립팅과 클릭재킹에 대한 가장 강력한 브라우저 측 방어 수단 중 하나이고, 가장 망치기 쉬운 수단 중 하나이기도 하다. 지시문이 많고, 구문은 너그럽지 않으며, 비슷한 이름의 헤더 셋이 미묘하게 다른 동작을 하고, 해시·nonce·'unsafe-inline'의 관계는 명세를 두 번 읽지 않으면 명확하지 않다. ZeroTool의 CSP 헤더 생성기는 작업대를 제공한다: 프리셋을 고르고, 필요한 키워드 칩을 클릭하고, 인라인 스크립트를 붙여넣어 해시를 계산하고, 결과를 HTTP 헤더, HTML <meta> 태그, Express 미들웨어 스니펫, Nginx 지시문 형태로 복사한다. 본 가이드는 사용법, 엄격 프리셋에 담긴 설계 결정, 그리고 검증 패널이 가리키는 함정들을 설명한다.
CSP가 왜 필요한가
Content Security Policy는 HTTP 응답 헤더다. 브라우저는 페이지 로드 시 한 번 파싱한 뒤, 그 페이지가 가져오려는 모든 리소스—스크립트, 스타일시트, 이미지, 폰트, 프레임, 커넥션, prefetch—를 허용할지 차단할지 결정한다. CSP가 없으면 페이지는 어떤 URL이든 가져올 수 있다. 엄격한 CSP가 있으면 페이지는 명시적으로 신뢰한 것만 가져올 수 있고, 공격자가 HTML에 <script> 태그를 주입하더라도 브라우저가 실행을 거부한다. 같은 메커니즘이 frame-ancestors로 프레이밍 공격을, base-uri로 base 태그 하이재킹을, form-action으로 폼 데이터 유출을 막는다.
CSP는 입력 새니타이즈, 출력 인코딩, 템플릿 자동 이스케이프를 대체하지 않는다. 마지막 방어선이며, 다른 모든 것을 빠져나간 XSS 페이로드를 잡아내는 선이다. 현대의 W3C CSP3 명세, Google의 CSP Evaluator, OWASP의 strict CSP 가이드는 모두 같은 골격으로 수렴한다: 인라인 스크립트엔 nonce 또는 해시, 자식 스크립트가 신뢰를 상속받도록 'strict-dynamic', 1차 자원엔 'self', object-src엔 'none'—레거시 플러그인 콘텐츠가 단연 최대 XSS 우회 표면이기 때문이다.
다섯 가지 흔한 작업 흐름
| 시나리오 | 시작 프리셋 | 핵심 지시문 구성 |
|---|---|---|
| 그린필드 SPA, 서드파티 CDN 없음 | Strict | script-src 'self' 'strict-dynamic' + 요청별 nonce |
| Google Fonts와 Analytics를 쓰는 마케팅 사이트 | Moderate | font-src 'self' fonts.gstatic.com, script-src에 인라인 GA 부트스트랩 해시 |
| 인라인 이벤트 핸들러가 곳곳에 있는 레거시 앱 | Basic, Report-Only로 시작 | 보고서를 보고, 다시 쓸 수 없는 핸들러에는 'unsafe-hashes' 추가 |
| 내부 관리 도구, 프레이밍 잠그고 싶음 | Strict | frame-ancestors 'none'로 어디에도 임베드되지 않게 |
| API 게이트웨이 응답 미리보기 | Strict + report-only | default-src 'self', connect-src 'self', 1주 깨끗하면 enforce로 전환 |
각자 다른 프리셋에서 시작하는 이유는 보안과 깨짐 사이의 트레이드오프 위치가 다르기 때문이다. Google Analytics가 안 떠서 부서지는 마케팅 사이트는 살짝 느슨한 정책보다 나쁘다. 공격자가 피싱 키트로 iframe할 수 있는 관리 패널은 용납할 수 없다. 도구의 프리셋 셀렉터는 그 곡선상의 네 위치를 인코딩해두었으니, 시작점을 고르고 거기서 조정하면 된다.
작업대 레이아웃
페이지를 열면 상단 툴바가 프리셋과 모드를 담당한다. Strict 프리셋은 OWASP의 “strict CSP” 권고에 대응한다: script-src에 'self' 'strict-dynamic', object-src에 'none', base-uri와 form-action에 'self'. Moderate는 style-src에 'unsafe-inline'을 더한다—오래된 CSS-in-JS 라이브러리는 여전히 인라인 스타일을 내보낸다. img-src엔 https:도 추가, 대부분의 프로덕트 사이트는 몇 개 CDN에서 이미지를 가져오기 때문이다. Basic은 default-src 'self' 한 줄만 주고 지시문을 한 줄씩 쌓아 가게 한다. Empty는 정확히 무엇을 쓸지 이미 알고 있는 경우를 위한 백지다.
Mode 토글은 Content-Security-Policy(강제 Enforce)와 Content-Security-Policy-Report-Only(보고만)를 전환한다. Report-Only는 위반을 report-uri 또는 report-to 엔드포인트로 보내기만 하고 아무것도 막지 않는다. 이것이 먼저 배포하는 모드다.
툴바 아래, 모든 지시문은 카드다. 각 카드에는 세 줄의 소스 선택기가 있다: 키워드('self', 'none', 'strict-dynamic', 'unsafe-inline', 'unsafe-eval'), 스킴(https:, data:, blob:, mediastream:), 호스트·경로·해시·nonce용 자유 입력란. + nonce 버튼은 crypto.getRandomValues로 새 base64 토큰을 생성한다. 칩을 클릭해 소스를 토글하고, 태그의 ×로 소스를 제거한다.
폼 하단의 해시 계산기는 인라인 <script> 또는 <style> 블록의 내용—여는 태그와 닫는 태그 사이의 정확한 바이트—을 받아 Web Crypto API로 SHA-256 / SHA-384 / SHA-512를 돌린다. 출력은 CSP가 기대하는 형식: 'sha256-<base64>'. 드롭다운에서 고른 script-src나 style-src에 한 번에 추가된다.
출력 패널은 네 개 탭을 렌더링한다:
- HTTP header—오리진 서버가 실제로 보내는 형식. 이것이 정규 형태.
- HTML
<meta>—헤더를 설정할 수 없는 정적 호스트용 폴백. CSP가<meta>로 전달될 때frame-ancestors,report-uri,report-to,sandbox가 조용히 무시된다는 것을 도구가 경고한다. - Express (helmet)—helmet을 쓰는 Node 서버용 즉시 사용 코드.
useDefaults: false에 주의—이게 없으면 정책이 helmet의 내장 기본값과 조용히 병합된다. - Nginx—
add_header ... always;지시문.always플래그가 중요하다—없으면 Nginx가 에러 응답에서 헤더를 건너뛰어 500 페이지가 무방비가 된다.
출력 아래의 검증 목록이 의심스러운 조합을 실시간으로 표시한다: 'none'이 다른 소스와 섞임, 해시 때문에 'unsafe-inline'이 무력화됨, <meta> 정책에 frame-ancestors가 들어감, default-src 누락, http: 스킴이 비보안 fetch를 허용함 등.
해시 vs. nonce, 그리고 보통 둘 다 필요한 이유
인라인 <script>...</script> 블록은 CSP 롤아웃이 롤백되는 가장 흔한 단일 원인이다. 안일한 수정은 script-src에 'unsafe-inline'을 더하는 것이다. 작동은 한다, 그러나 그것은 XSS를 잡아내는 CSP의 핵심을 무력화시킨다—정책의 존재 이유 자체다.
올바른 수정은 특정 인라인 스크립트만 허용하는 것이다. 두 가지 메커니즘이 있다:
해시는 <script> 태그 내 정확한 바이트의 SHA-256(또는 384, 512) 다이제스트를 base64로 인코딩한 값이다. 스크립트 내용이 한 글자라도 달라지면 해시가 더 이상 일치하지 않고 브라우저가 차단한다. 해시는 결정론적이고 캐시 친화적이다: 빌드 타임에 미리 계산해 정적 헤더에 실어 보낼 수 있다. 해시는 분석 스니펫, 크리티컬 CSS, 페이지와 함께 배포되어 거의 변하지 않는 인라인 콘텐츠에 적합하다.
nonce는 서버가 요청별로 생성하는 무작위 base64 토큰이다. 서버는 'nonce-XYZ'가 담긴 헤더를 발행하고, 렌더링하는 모든 <script nonce="XYZ"> 태그에 같은 XYZ를 찍는다. 브라우저는 nonce 속성이 일치하는 스크립트를 실행하고 나머지는 차단한다. nonce는 인라인 콘텐츠가 가변적인 서버 렌더링 HTML(CSRF 토큰, 사용자 ID, 페이지별 부트스트랩)에 적합하다.
페이지의 해시 계산기는 정적 케이스를 위한 것이다. 동적 케이스에서는 + nonce로 샘플 nonce를 만들고 서버 측에서 요청 시점에 교체한다.
두 가지 모두 'strict-dynamic'과 잘 결합한다. 어떤 스크립트가 신뢰되면(해시 또는 nonce 경유), 'strict-dynamic'이 “그 스크립트가 로드하는 어떤 스크립트도 신뢰한다”고 추이적으로 선언한다. 즉, 그 신뢰된 스크립트가 가져올 수 있는 모든 CDN을 일일이 나열할 필요가 없다. 또한 공격자가 합법적으로 허용 목록에 오른 CDN에 자기 페이로드를 호스팅하는 종류의 우회를 막아준다—'strict-dynamic'이 자식 스크립트에 대해 호스트 기반 허용 목록을 무효화하기 때문이다.
미묘한 점: script-src에 해시나 nonce가 있으면 현대 브라우저는 'unsafe-inline'을 무시한다. 이것이 명세의 “strict mode”다: 해시나 nonce를 쓸 만큼 정교하다면, 브라우저는 'unsafe-inline'을 구형 브라우저용 호환 힌트로 취급하고 그 외에는 버린다. 도구의 검증 패널은 이를 정보 수준의 노트로 표면화한다—두 키워드가 함께 나열되어도 당황하지 않게 말이다.
검증기가 지켜보는 다섯 가지 함정
'none'은 단독이어야 함
CSP3 명세는 분명하다: 소스 목록에 'none'이 있으면 다른 모든 소스는 폐기된다. script-src 'none' https://cdn.example.com이라고 쓰면 버그다—브라우저가 그 CDN을 무시한다. 검증기는 이를 강한 경고로 표시한다.
frame-ancestors는 HTTP 전용
HTML <meta http-equiv="Content-Security-Policy"> 태그는 존재하지만, CSP가 그 경로로 도착할 때 의도적으로 무시되는 네 가지 지시문이 있다: frame-ancestors, sandbox, report-uri, report-to. 이유는 이 지시문들이 문서가 완전히 파싱되기 전에 효력을 발휘해야 하는데, meta 태그는 문서 중간에서 파싱되기 때문이다. GitHub Pages 같은 정적 호스트에서 프레이밍 보호가 필요하면 진짜 헤더를 보내야 한다—보통 CDN이나 worker를 통해서.
default-src 누락은 폴백을 잃는다
여러 fetch 지시문(script-src, style-src, img-src 등)은 명시되지 않으면 default-src로 폴백한다. 미래 CSP 개정에서 추가되는 새 지시문도 호환을 위해 default-src로 폴백한다. default-src를 완전히 빼면 정책이 취약하다: 브라우저가 새 지시문을 출시하는 날, 사이트는 조용히 그것을 허용해버린다. default-src 'self' 한 줄이라도 없는 것보단 낫다.
http:는 “정책 없음”과 같지 않다
img-src https: http:로 dev와 prod의 CDN을 둘 다 통과시키고 싶어진다. 그러나 http: 스킴은 평문 비보안 fetch를 허용한다—Wi-Fi pineapple을 든 카페 공격자가 응답을 MITM해서 이미지를 다른 것으로 바꿀 수 있다는 뜻이다. https:만 쓰자. HTML 안의 레거시 http:// URL을 다뤄야 하면 upgrade-insecure-requests를 쓰자. 검증기는 http: 소스를 정보 수준 경고로 강조한다.
커스텀 호스트에 세미콜론을 넣지 말 것
CSP는 세미콜론으로 지시문을 구분한다. 호스트나 경로 안의 세미콜론은 정책을 끊고 그 뒤의 모든 지시문을 쓰레기로 만든다. 도구는 세미콜론이 들어있는 소스 추가를 거부한다.
생성한 헤더 배포하기
마음에 드는 정책이 생기면 네 개 출력 탭이 흔한 배포 표면을 덮는다. 각각의 멘탈 모델:
# Nginx: 보통 server { } 블록 안에
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'strict-dynamic'; object-src 'none'; base-uri 'self'" always;
# Apache: .htaccess 또는 httpd.conf, "always"는 암묵적
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'strict-dynamic'; object-src 'none'; base-uri 'self'"
// Express + helmet
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
useDefaults: false,
directives: {
"default-src": ["'self'"],
"script-src": ["'self'", "'strict-dynamic'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
"object-src": ["'none'"],
"base-uri": ["'self'"],
}
}));
# Cloudflare Pages _headers 파일
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'strict-dynamic'; object-src 'none'; base-uri 'self'
Vercel은 같은 문자열을 vercel.json의 headers 배열에 붙여넣는다. Netlify는 netlify.toml에 추가한다. S3 + CloudFront로 정적 내보내기를 하면 CloudFront 응답 헤더 정책에 설정한다. 요점은 정책 텍스트가 어디서나 같다는 것이고, 바뀌는 건 외피의 설정 구문뿐이다.
실제로 통하는 단계별 롤아웃 계획
거의 모든 성공적인 CSP 배포는 같은 모양을 따른다:
- 도구에서 Strict 프리셋을 고르고
Content-Security-Policy-Report-Only와report-uri(또는report-to그룹) 엔드포인트로 배포. 아직 enforce하지 말 것. - 보고서를 1~2주간 관찰한다. 자기 코드 경로(인라인
onclick, 잊고 있던 CDN), 브라우저 확장의 스크립트 주입, prerender 봇으로부터의 위반이 보일 것이다. - 분류: 자기 코드의 위반은 수정한다(인라인 핸들러를 addEventListener로 옮기고, 옮길 수 없는 분석 스니펫에 해시 추가). 확장 위반은 서버 측에서 필터링한다. prerender 봇 위반은 보통 어떤 지시문을 조이라는 뜻이다.
- 조이기: 3단계의 교훈을 도구에 가져가 정책을 재구성한다.
script-src에 특정 인라인 부트 스니펫용 해시가 필요할 수도 있고,connect-src에 에러 트래커 도메인이 필요할 수도 있다. - enforce로 전환한다—헤더 이름을
Content-Security-Policy-Report-Only에서Content-Security-Policy로 바꾼다. 도구의 Mode 토글이 한 번 클릭으로 처리해준다. - enforce 후에도 보고서 수집을 계속한다. 새 SDK, 새 서드파티 스크립트, 새 페이지가 정책에 걸린다. 보고 엔드포인트는 조기 경보 시스템이다.
이 계획을 탈선시키는 두 가지 실패 모드: 1단계 건너뛰기(첫날 바로 Enforce), 보고 엔드포인트를 쓰기 전용으로 취급(보고서를 수집하지만 읽지 않음). 도구는 Strict 프리셋을 처음 고르면 Mode를 Report-Only로 기본 설정해 1단계를 권장한다. 보고 엔드포인트는 본인의 책임이다.
기존 CSP 도구와의 차이
Report URI의 CSP Wizard와 csper.io는 사이트의 실제 트래픽을 관찰해 정책을 만든다—이미 CSP를 배포했고 다듬고 싶을 때 좋다. 0에서 출발해 첫날에 합리적인 프리셋이 필요하다면 그리 좋지 않다. OWASP CSP Generator는 미리보기와 검증이 없는 JSON 폼이다. 현재 사이트의 CSP를 보여주는 Chrome 확장 다수는 점검에는 유용하지만 작성에는 부적합하다.
ZeroTool의 도구는 범위가 더 작고 실행이 더 깔끔하다. 계정도 없고 트래픽 캡처도 없고 텔레메트리도 없다—정책은 브라우저 탭을 떠나지 않는다. 검증 패널은 모든 이론적 위반을 카탈로그하지 않고 실제로 배포를 부수는 다섯 가지 함정만 표면화한다. 해시 계산기는 SubtleCrypto로 브라우저에서 돈다, 그래서 프로덕션 코드를 붙여넣어도 로깅을 걱정할 필요가 없다. 네 개 출력 형식은 실제로 배포하는 표면을, 그 표면이 실제로 받아들이는 구문으로 덮는다.
더 읽기
- W3C Content Security Policy Level 3—명세 그 자체.
- OWASP Content Security Policy Cheat Sheet—실용적 다층 방어.
- Google의 Strict CSP 가이드—nonce +
'strict-dynamic'의 근거. - MDN: Content-Security-Policy—지시문 레퍼런스.
- ZeroTool Meta 태그 생성기—
<head>블록의 SEO/소셜 절반을 위한 동반 도구. - ZeroTool 해시 생성기—CSP 컨텍스트 밖에서 원시 SHA-256이 필요할 때.
- ZeroTool .htaccess 생성기—Apache 배포 표면용.
- ZeroTool Robots.txt 생성기—크롤러 제어의 다른 절반.
CSP는 한 번 설정하고 잊을 수 있는 것이 아니다. 의존성, 서드파티 스크립트, 위협 모델이 변하면 함께 변하는 살아있는 정책이다. 다음 보안 리뷰까지 생성기를 탭에 열어두자.