'unsafe-inline' 한 줄이 CSP 방어를 무력화하는 이유와 nonce 대안
Content-Security-Policy(CSP) 헤더를 처음 도입할 때 가장 흔하게 저지르는 실수가 script-src 'self' 'unsafe-inline'처럼 'unsafe-inline'을 습관적으로 넣는 것입니다. 당장 에러 없이 페이지가 동작한다는 이유만으로 넘어가기 쉽지만, 사실 이 한 줄이 CSP를 도입한 이유 자체를 지워버립니다. 이 가이드는 왜 그런지 원리를 짚고, CSP 생성기가 실제로 이 문제를 어떻게 다루는지, meta 태그 방식의 스펙상 제약까지 정리합니다.
1. CSP가 막으려는 공격은 정확히 무엇인가
CSP의 핵심 목적은 XSS(크로스 사이트 스크립팅) 방어입니다. 공격자가 게시판 댓글이나 URL 파라미터 등을 통해 <script>alert(document.cookie)</script> 같은 코드를 페이지에 주입했다고 가정하면, CSP 없이는 이 스크립트가 그대로 실행되어 세션 쿠키가 유출될 수 있습니다. CSP는 "브라우저가 실행을 허용할 스크립트의 출처"를 화이트리스트로 지정하는 헤더입니다. script-src에 명시되지 않은 출처의 스크립트는 브라우저가 아예 실행을 거부합니다.
2. 'unsafe-inline'이 이 방어를 통째로 꺼버리는 이유
문제는 CSP가 기본적으로 인라인 스크립트(HTML 안에 직접 쓴 <script> 태그, onclick 같은 이벤트 속성)를 출처 구분 없이 전부 차단한다는 데 있습니다. 그런데 script-src에 'unsafe-inline' 키워드를 추가하면, 이 인라인 차단 규칙 자체가 해제됩니다. 문제는 공격자가 주입하는 스크립트도 대부분 인라인 형태라는 점입니다 — 외부 파일을 새로 서버에 올릴 필요 없이 텍스트만 삽입하면 되기 때문입니다. 즉 'unsafe-inline'을 넣는 순간, "출처를 화이트리스트로 제한한다"는 CSP의 존재 이유였던 인라인 차단이 무력화되어, 정상적으로 로드되는 스크립트와 공격자가 주입한 스크립트를 브라우저가 구분하지 못하게 됩니다. 다른 지시어(예: img-src, font-src)를 아무리 촘촘하게 설정해도 script-src에 'unsafe-inline'이 남아 있으면 XSS 방어라는 관점에서는 사실상 CSP를 켜지 않은 것과 큰 차이가 없습니다.
3. nonce 기반 대안: "이번 요청에서만 유효한 암호"
그렇다고 인라인 스크립트를 아예 못 쓰면 실무에서 불편한 경우가 많습니다(서버 렌더링 시 동적 값을 즉시 심어야 할 때 등). 이때 쓰는 것이 nonce(number used once) 방식입니다. 서버가 매 요청마다 무작위 문자열을 생성해 두 곳에 동시에 넣습니다 — 스크립트 태그의 nonce="랜덤값" 속성, 그리고 CSP 헤더의 'nonce-랜덤값'. 브라우저는 두 값이 정확히 일치하는 인라인 스크립트만 실행을 허용합니다. 공격자가 페이지에 스크립트를 주입하더라도 서버가 그 요청 시점에 발급한 nonce 값을 알아낼 방법이 없으므로(매 요청마다 값이 바뀌고, HTML 응답을 가로채지 않는 한 예측 불가능하므로) 주입된 스크립트는 nonce 불일치로 차단됩니다.
4. hash 기반 대안: "내용이 곧 통행증"
서버가 요청마다 랜덤값을 심기 어려운 정적 사이트라면 hash 방식을 씁니다. 허용하고 싶은 인라인 스크립트의 정확한 내용을 SHA-256 등으로 해시한 값을 CSP에 'sha256-해시값' 형태로 등록해두면, 브라우저는 실행하려는 인라인 스크립트의 해시를 계산해 등록된 값과 비교합니다. 한 글자라도 다르면 해시가 달라지므로 공격자가 주입한 코드는 통과하지 못합니다. 다만 스크립트 내용이 바뀔 때마다 해시도 다시 계산해 CSP를 갱신해야 하므로, 내용이 자주 바뀌지 않는 정적 인라인 코드에 적합합니다.
| CSP script-src 설정 | 정상 인라인 스크립트 | 공격자 주입 스크립트 |
|---|---|---|
| 'unsafe-inline' | 실행됨 | 실행됨(차단 안 됨) |
| 'nonce-a1b2c3...' | nonce 일치 시 실행됨 | nonce 없어 차단됨 |
| 'sha256-Xy8f...' | 해시 일치 시 실행됨 | 해시 불일치로 차단됨 |
5. meta 태그로 CSP를 걸 때의 스펙상 제약
서버 설정 없이 <meta http-equiv="Content-Security-Policy" content="...">만으로 CSP를 적용하려는 경우도 많은데, 여기에는 스펙상 명확한 한계가 있습니다. frame-ancestors, report-uri, report-to, sandbox 지시어는 meta 태그에서 아예 동작하지 않습니다 — HTTP 응답 헤더로 전달할 때만 유효합니다. 특히 frame-ancestors는 클릭재킹 방어에 쓰이는 지시어인데, meta 태그로는 처음부터 이 방어를 켤 수 없다는 뜻입니다. 실제로 CSP 생성기의 코드를 확인해보면 META_UNSUPPORTED 목록에 이 4개 지시어를 정의해두고, HTML meta 태그용 출력을 만들 때는 이 목록에 해당하는 지시어를 자동으로 제외하고 있습니다. 즉 도구 차원에서 이미 이 제약을 반영해, 사용자가 meta 태그 출력에 frame-ancestors를 넣었다가 "설정했는데 안 먹힌다"는 혼란을 겪지 않도록 처리되어 있습니다. 프로덕션 환경이라면 meta 태그보다 서버(Nginx·Apache 설정 또는 애플리케이션 코드)에서 HTTP 헤더로 직접 CSP를 내려주는 방식이 권장됩니다.
6. 설정 후에는 검증까지가 한 세트
CSP는 문법 오류가 있어도 브라우저가 조용히 일부만 무시하고 넘어가는 경우가 있어, 직접 눈으로 검토하는 것만으로는 놓치는 지시어가 생기기 쉽습니다. CSP 생성기로 헤더 값을 만든 뒤에는 CSP 검증기로 실제 문법과 위험한 키워드(예: script-src의 '*'나 'unsafe-inline' 잔존 여부)가 남아있지 않은지 다시 확인하는 것이 안전합니다.
자주 묻는 질문
Q. 'unsafe-inline'을 넣으면 CSP를 아예 안 쓰는 것과 같나요?
완전히 같지는 않습니다. img-src, connect-src 등 다른 지시어는 여전히 유효해 리소스 출처 제한 효과는 남아있습니다. 다만 script-src의 'unsafe-inline'은 XSS 방어라는 CSP의 핵심 목적을 무력화하므로, 보안 관점에서는 사실상 그 부분의 방어가 꺼진 것과 같다고 보는 것이 정확합니다.
Q. nonce와 hash 중 어느 것이 더 안전한가요?
보안 수준은 비슷하지만 용도가 다릅니다. 서버가 매 요청마다 동적으로 값을 생성할 수 있다면 nonce가 관리하기 쉽고, 내용이 고정된 정적 인라인 스크립트라면 hash가 서버 로직 없이도 적용 가능합니다.
Q. meta 태그 CSP로 frame-ancestors를 설정하면 에러가 나나요?
브라우저가 에러를 띄우지는 않지만 해당 지시어가 조용히 무시됩니다. 즉 설정했다고 착각하기 쉬운 대표적인 함정입니다. 클릭재킹 방어가 필요하다면 반드시 HTTP 응답 헤더로 CSP를 내려주세요.
Q. Google Analytics 같은 외부 스크립트를 쓰면서도 'unsafe-inline' 없이 설정할 수 있나요?
가능합니다. 외부 스크립트는 script-src에 해당 도메인(예: googletagmanager.com)을 명시하면 되고, 페이지 내 인라인 초기화 코드만 nonce나 hash로 별도 허용하면 'unsafe-inline' 없이도 정상 동작시킬 수 있습니다.