Content-Security-Policy는 가장 어려운 헤더다

배포된 CSP 대부분은 아무것도 안 한다. 구글이 측정했다: 스크립트를 제한하려는 정책의 94.7%가 우회 가능. 문법은 쉽다. 어려운 이유는 따로 있다.

2016년 구글의 연구자들이 아무도 규모 있게 안 해본 일을 했다: 160만 개 호스트에서 Content-Security-Policy 헤더를 수집해, 그 정책들이 실제로 뭔가를 막는지 검사한 것이다.

답은 ‘아니오’였다. 스크립트 실행을 제한하려는 정책의 94.68%가 손쉽게 우회됐다. 더 나쁜 건, CSP를 배포하기라도 한 호스트의 99.34%가 CSP의 존재 이유인 크로스사이트 스크립팅(XSS)에 대해 의미 있는 보호를 전혀 못 주는 정책을 쓰고 있었다는 점이다. 헤더를 깜빡한 사이트들이 아니다. 헤더를 설정하고, 배포하고, 보안 리뷰에서 체크박스를 채웠을 사이트들이다.

그 논문 제목이 “CSP Is Dead, Long Live CSP”이고, 10년이 지난 지금도 진단은 유효하다. CSP는 설정하는 것과 가치를 얻는 것이 거의 무관한 유일한 HTTP 보안 헤더다. 길고 공식적으로 보이는 정책이 정확히 아무것도 안 할 수 있다. 그 간극이 CSP를 가장 어려운 헤더로 만든다 — 그리고 그 이유는 문법과 거의 상관이 없다.

화이트리스트가 함정이었다

CSP의 원래 멘탈 모델은 화이트리스트였다. 스크립트를 허용할 출처를 나열하고 — script-src 'self' https://apis.google.com https://cdn.example.com — 나머지는 브라우저가 차단한다. 직관적이다. 틀렸다.

문제는 화이트리스트에 올린 출처는 거기 호스팅된 불운한 것들까지 통째로 올린 거라는 점이다. 구글 연구진은 가장 흔히 화이트리스트에 오른 15개 도메인 중 14개가 정책 전체를 무력화하는 엔드포인트를 최소 하나씩 호스팅하고 있음을 발견했다 — 공격자가 제어하는 함수명을 반사하는 JSONP 콜백, 허용된 출처를 XSS 가젯으로 바꿔버리는 낡은 AngularJS 빌드, 공격자 페이로드를 신뢰받는 호스트로 세탁하는 오픈 리다이렉트. 라이브러리 하나 로드하려고 CDN을 화이트리스트에 올렸다. 그 CDN은 JSONP 엔드포인트도 서빙한다. 이제 script-src https://that-cdn.com은 “임의의 공격자 자바스크립트를 실행하라”는 뜻이고, 당신의 정책은 장식이다.

연구의 개별 정책 75.81%가 정확히 이 구멍을 갖고 있었다. 화이트리스트는 공격자를 제약하지 못했다; 우회를 한 단계 길게 만들었을 뿐이다. 그리고 감사로 빠져나갈 수도 없다. 그 서드파티들이 내일 무엇을 호스팅할지 당신이 통제하지 못하니까.

문법이 당신을 속이는 이유

CSP를 진짜로 위험하게 만드는 건, 실패가 조용하고 문법이 그 실패를 권한다는 점이다.

거의 모든 실제 애플리케이션엔 인라인 스크립트가 있다 — 애널리틱스 스니펫, HTML 안의 이벤트 핸들러, 프레임워크의 부트스트랩 덩어리. CSP는 기본적으로 인라인 스크립트를 차단한다. 인라인 주입이 바로 XSS의 정체이기 때문이다. 그래서 진짜 정책을 배포하는 순간 앱이 깨진다. 가장 저항이 적은 길, 예전 모든 튜토리얼이 보여준 그 길은 'unsafe-inline'을 추가하는 것이다. 페이지가 다시 작동한다. 헤더는 여전히 거기 있다. 다들 넘어간다.

하지만 'unsafe-inline'은 “모든 인라인 스크립트 허용”이고, 이는 “주입된 스크립트도 허용”이고, 이는 XSS 보호가 아예 없는 것과 같다. 헤더는 존재한다. 스캐너가 응답에서 Content-Security-Policy:를 보고 초록불을 켠다. 정책은 자기가 금지해야 할 바로 그 하나를 허용한다. 이게 CSP가 99.34%에 들어가는 가장 흔한 방식이다: 없는 게 아니라, 사이트가 안 깨지게 누군가 추가한 키워드 하나에 거세당한 것.

CSP는 정직한 설정을 벌하고 아무것도 안 하는 설정에 상을 준다. 다른 어떤 보안 헤더도 이렇게 동작하지 않는다. nosniff는 설정됐거나 안 됐거나다. HSTS는 max-age가 있거나 없거나다. CSP는 완전히 존재하면서 완전히 쓸모없을 수 있고, 응답의 그 무엇도 어느 쪽인지 알려주지 않는다.

탈출구는 화이트리스트가 아니라 nonce다

같은 연구진이 제안하고 — 이후 구글 자사 서비스 전반에 배포한 — 해법은 호스트를 화이트리스트에 올리는 걸 멈추고 개별 스크립트를 보증하기 시작하는 것이다. 두 메커니즘이 이걸 한다: nonce(응답마다 생성되는 무작위 값으로, 헤더와 모든 정당한 <script nonce="..."> 태그에 함께 붙는다) 또는 스크립트 내용의 해시. 브라우저는 맞는 nonce를 달거나 알려진 해시와 일치하는 스크립트만 실행한다. 주입된 <script>엔 둘 다 없으니 — 어느 출처를 사칭하든 — 실행되지 않는다.

영리한 부분은 'strict-dynamic'이다. nonce 기반 정책은 그 스크립트가 또 다른 스크립트를 로드하기 전까지는 훌륭하다(그리고 대부분의 번들러와 태그 매니저가 그렇게 한다). 전이적으로 로드되는 모든 URL을 나열하는 건 가망이 없다. 'strict-dynamic'은 말한다: nonce로 이미 신뢰한 스크립트는 또 다른 스크립트를 로드해도 되고, 그 신뢰가 전파된다. URL 목록 관리를 아예 그만두게 된다. 정책은 대략 script-src 'nonce-{무작위}' 'strict-dynamic';이 되고, 당신의 CDN이 내일 뭘 호스팅하든 신경 쓰지 않는다. 신뢰하는 게 출처가 아니기 때문이다. 신뢰하는 건 nonce다.

그 아래엔 정말 우아한 하위 호환 엔지니어링이 있다. 브라우저가 nonce를 보면, 명세는 'unsafe-inline'무시하라고 한다. 그래서 script-src 'nonce-r4nd0m' 'strict-dynamic' 'unsafe-inline' https:라고 쓰면: 최신 브라우저는 nonce와 strict-dynamic을 따르고 나머지를 무시한다; 더 오래된 CSP2 브라우저는 strict-dynamic은 무시하되 nonce는 따른다; 아주 옛 CSP1 브라우저는 'unsafe-inline' https:로 폴백한다. 헤더 하나, 세 단계의 강제력, 각각 브라우저가 할 수 있는 만큼 엄격하게. User-Agent를 스니핑하지 않는다. 그 폴백들이 하중을 지탱한다.

그런데 왜 다들 이렇게 안 하나

엄격한 nonce 기반 CSP는 헤더가 줄 수 없는 무언가를 요구하기 때문이다: 당신의 페이지가 어떻게 조립되는지에 대한 통제권.

nonce는 요청마다 새로 생성돼 템플릿 레이어를 거쳐 모든 스크립트 태그에 꿰여야 한다 — 즉 서버가 HTML을 렌더해야 하고, 이는 공격적인 페이지 캐싱과 충돌하며, 일부 스크립트 태그가 CMS나 마케팅 도구나 당신의 nonce를 들어본 적 없는 서드파티 태그 매니저에서 올 때 무너진다. 해시 기반 정책은 스크립트가 바뀔 때마다 재해시한다는 뜻이고, 이는 빌드 파이프라인이 정책을 소유해야 한다는 뜻이다. 어느 쪽이든 CSP를 제대로 하려면 인라인 스크립트, 레거시 템플릿, 흩어진 애널리틱스, 그리고 2019년에 누군가 <head>에 붙여넣은 어느 벤더의 <script> 태그를 직면해야 한다.

이것이 CSP가 가장 어려운 헤더인 진짜 이유다. nosniff는 한 번 설정하는 문자열이다. HSTS는 숫자다. CSP는 당신의 프런트엔드가 실제로 얼마나 규율 있는지 찍는 엑스레이이고, 대부분의 프런트엔드는 그걸 안 보고 싶어 한다. 헤더는 설정 한 줄을 추가하라고 요구하지 않는다; 페이지의 모든 스크립트가 어디서 오는지 알라고 요구한다. 많은 팀이 절반쯤 가서 깨닫는다, 아무도 모른다는 걸.

그게 또한 할 가치가 있는 이유다. 사이트는 깔끔한 초록 체크 한 줄을 모을 수 있다 — nosniff, Referrer-Policy, X-Frame-Options, 싼 것들 전부 — 그러면서도 아무것도 안 하는 깨진 'unsafe-inline' CSP를 그대로 배포하고, 스캐너는 그걸 A로 부른다. 체크 표시는 당신이 헤더를 설정했는지를 잰다. CSP가 진짜인지는 다른 걸 잰다: 어느 코드가 당신 것인지 브라우저에게 말해줄 만큼 당신이 자기 애플리케이션을 잘 아는지. 그 질문은 불편하고, 바로 그래서 답이 가질 가치가 있다.

토론 참여

← 블로그로 돌아가기