가장 쉬운 보안 헤더가 이기고 있다. 가장 쉬운 보안 헤더가 쉬우니까.

바보같이 당연하게 들리지만 이야기 전체가 이거다.

블로그 글 대신 실제 채택 데이터를 보면, “한 줄 설정” 헤더와 “아키텍처 부채를 드러낼” 헤더 사이의 격차가 엄청나다. 팀들은 기꺼이 X-Content-Type-Options를 켠다. X-Frame-Options도 종종 설정한다. HSTS를 관리하는 곳도 꽤 있다. 그다음 진짜 Content Security Policy나 현대적 교차 출처 격리 헤더를 요청하면 절벽이다 — 서버 스니펫 복사가 아닌 애플리케이션 변경이 필요한 것 말이다.

데이터가 이에 대해 노골적이다.

숫자가 보여주는 것

HTTP Archive Web Almanac 같은 대규모 웹 측정 프로젝트는 매번 같은 그림을 그린다. 아래 수치는 Almanac 2024년 보안 장(데스크톱 수집분, 반올림)에서 가져왔다. 해마다 조금씩 움직이지만 모양은 그대로다.

사이트의 절반에서 3분의 1 정도:

  • X-Content-Type-Options: nosniff(약 48%) — 한 줄이면 끝나고, 설정할 것도 없고, 깨지는 것도 없다. 가장 널리 배포된 보안 헤더다.
  • X-Frame-Options(약 37%) — 역시 단순하다. DENY나 SAMEORIGIN이면 된다. Django와 Rails를 비롯한 몇몇 인기 프레임워크는 기본으로 보낸다.
  • Strict-Transport-Security(약 34%) — 호스팅 플랫폼과 CDN이 체크박스 하나로 만들어 놓았다. 하지만 그중 includeSubDomains를 넣은 정책은 3분의 1 남짓이고, preload는 더 적다.

다섯 곳 중 한 곳 이하:

  • Content-Security-Policy(약 19%, 2022년 15%에서 증가) — 가장 중요한데 가장 덜 배포된 헤더다. 이 중 상당수는 스크립트를 전혀 제한하지 않는다. 가장 흔한 정책은 upgrade-insecure-requests나 frame-ancestors만 설정한다. 게다가 Almanac은 CSP가 있는 사이트 열 곳 중 아홉 곳 정도의 스크립트 정책에서 unsafe-inline을 발견했다. 계산해 보면, 주입된 인라인 스크립트를 실제로 막는 CSP는 웹 전체의 한 자릿수 초반 비율에 불과하다.
  • Referrer-Policy(약 17%) — 늘고는 있지만 보편과는 거리가 멀다.

거의 없음:

  • Permissions-Policy(3% 미만).
  • Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, Cross-Origin-Embedder-Policy — 교차 출처 격리 3종 세트. 각각 1% 안팎이거나 그보다 적다. 거의 대형 플랫폼에서만 쓴다.

CSP 헤더는 실제로 어떻게 생겼나

“CSP 헤더”가 뭔지 궁금해서 들어왔다면: Content-Security-Policy는 페이지가 어떤 출처의 스크립트, 스타일, 프레임 같은 리소스를 쓸 수 있는지 브라우저에 알려 주는 HTTP 응답 헤더 하나다. 그 밖의 것은 차단된다. 스크립트 주입에 성공해도 그냥 죽은 문자열이 되는 이유가 이것이다. 오늘날 제 역할을 하는 정책은 이렇게 생겼다.

Content-Security-Policy: script-src 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'

서버는 응답마다 새 무작위 nonce를 만들어 정상적인 <script nonce="R4nd0m"> 태그 하나하나에 붙인다. 주입된 스크립트에는 nonce가 없으니 실행되지 않는다. strict-dynamic은 신뢰받은 스크립트가 자기 의존성을 불러올 수 있게 해 준다. 처음에는 차단 없이 위반만 보고하는 Content-Security-Policy-Report-Only로 배포하고, 보고가 잠잠해지면 실제로 막는 헤더로 바꾼다.

CSP 격차가 핵심이다

다른 헤더는 전부 설정 한 줄이다. CSP는 엔지니어링 프로젝트다.

X-Content-Type-Options: nosniff 설정은 2초 걸리고 거의 아무것도 안 깨뜨린다. 기능적 CSP 설정은 페이지의 모든 스크립트를 감사하고, 인라인 스크립트를 nonce나 해시로 교체하고, 모든 리소스 오리진을 제어하고, 광범위하게 테스트하는 것이다. 수년간 축적된 프론트엔드 코드, 서드파티 분석 태그, 임베디드 위젯, 마케팅 스크립트가 있는 사이트라면 몇 주 작업이다.

팀은 합리적인 일을 한다: 쉬운 헤더를 설정하고, 스캐너 검사를 통과하고, CSP를 무기한 미룬다.

unsafe-inline 문제

CSP 채택을 관대하게 세도 기능적 숫자는 보이는 것보다 나쁘다. 스크립트에 'unsafe-inline'을 포함하는 CSP는 임의의 인라인 스크립트 실행을 허용한다 — XSS 익스플로잇이 정확히 하는 짓. 정책이 존재한다. 다만 설계된 주요 공격에 대해 보호하지 않는다.

(한 가지 짚을 점: 정책에 nonce나 해시가 함께 있으면 최신 브라우저는 unsafe-inline을 무시한다. 아주 오래된 브라우저를 위한 대비책으로 남겨 두는 사이트가 많다. 아무것도 막지 못하는 건 unsafe-inline만 있는 정책이다.)

왜 unsafe-inline을 쓰나? 없으면 애플리케이션이 깨져서. 인라인 이벤트 핸들러, 템플릿의 인라인 스크립트 태그, 스크립트를 주입하는 서드파티 위젯 — 엄격한 CSP에서 전부 실패한다. 고치려면 리팩터링이 필요하다. unsafe-inline 추가는 한 줄이면 된다.

업계가 너무 강력한 헤더를 만들어서 대부분의 사이트가 그 힘을 무력화하는 모드에서만 배포할 수 있다.

HSTS: 가깝지만 아직

HSTS 채택률이 CSP보다 나아 보이는 건, 호스팅 플랫폼과 CDN이 스위치 하나로 켤 수 있게 해 둔 덕도 있다. 하지만 HSTS 품질이 천차만별이다.

max-age=86400(하루)은 최소한의 보호를 제공한다. 하루짜리 max-age는 공격자가 피해자가 24시간만 사이트를 안 방문하면 HSTS 보호가 만료된다는 뜻이다. 권장 최소는 1년(31536000초)이고, preload 목록도 이 값을 요구한다. 좋은 소식은 Almanac 기준 HSTS max-age 중앙값이 1년이라는 점이다. 나쁜 소식은 그보다 짧은 사이트들이 길게 꼬리를 끈다는 점이다.

좀비 헤더

X-XSS-Protection은 2024년 Almanac 기준 사이트의 약 23%가 아직 보낸다. CSP를 보내는 사이트보다 많다. Chrome은 2019년에 XSS Auditor를 제거했고, Edge는 그 전해에 필터를 뺐다. Firefox는 처음부터 없었다. 지금 주류 브라우저에서 이 헤더는 아무 일도 하지 않는다. 그런데 10년 전 블로그에서 복사해서 한 번도 정리 안 한 서버 설정에 남아있다.

거의 “카고 컬트 보안”의 마커다.

이게 말해주는 것

보안 헤더 채택에 명확한 패턴이 있다: 깨뜨릴 위험 없는 한 줄 설정 변경이면 채택이 괜찮다. 애플리케이션을 이해해야 하면 채택이 무너진다.

가장 중요한 헤더 — CSP와 preload이 있는 HSTS — 가 정확히 가장 많은 노력이 필요한 헤더다. 가장 쉽게 배포되는 헤더 — nosniff, X-Frame-Options — 는 현대 브라우저에서 가장 적은 한계 보호를 제공한다.

자기 헤더를 보고 CSP 빼고 전부 초록색이면, 어려운 부분을 아직 안 한 것이다. 어려운 부분에 보호가 실제로 산다.