보안 헤더 체크리스트는 보안이 아니다

사이트가 모든 헤더 스캐너에서 초록색 A+를 받고도 손쉽게 털릴 수 있다. 체크리스트는 편지가 아니라 봉투를 채점한다 — 그리고 그 틈에 공격이 산다.

얼마 전 “보안 헤더 추가”라는 제목의 티켓을 닫는 변경을 리뷰한 적이 있다. 헤더는 전부 들어가 있었다. Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, 죄다. 그 사이트를 지적하던 스캐너 점수가 흉한 F에서 깔끔한 A로 올라갔다. 티켓 닫힘, 박스 체크, 다들 다음으로 넘어갔다.

그들이 배포한 CSP는 대략 default-src *; script-src 'unsafe-inline' 'unsafe-eval'였다.

이 정책은 어디서든 스크립트를 허용하고, 인라인 스크립트를 허용하고, eval을 허용한다. 다시 말해 Content-Security-Policy가 금지하려고 존재하는 바로 그것들을 정확히 허용한다. 헤더는 있었다. 스캐너는 Content-Security-Policy를 보고 점수를 줬다. 그리고 사이트는 어제와 정확히 똑같이 털릴 수 있는 상태였다 — 다만 이제 대시보드에 XSS 문제가 처리됐다고 모두에게 말하는 초록색 체크가 붙었을 뿐이다.

이게 논지의 전부라 그냥 명확히 말하겠다: 보안 헤더는 문자열이고, 그 문자열이 뭔가를 하는지는 전적으로 그 값, 뒤에 있는 애플리케이션, 그리고 읽는 브라우저에 달렸다. 체크리스트는 존재를 채점한다. 공격자는 효력을 노린다. 이건 같은 축이 아니고, 그 둘 사이의 거리에 진짜 취약점이 전부 산다.

당신에게 거짓말하는 헤더

CSP부터 보자. 가장 나쁜 범인이자 가장 교훈적이니까.

Content-Security-Policy는 웹 앱이 가진 가장 강력한 방어 중 하나다, 진짜로. 촘촘한 정책은 크로스사이트 스크립팅 한 부류 전체를 무력화할 수 있다: 공격자가 페이지에 <script>를 주입해도, 정책이 허가하지 않았기에 브라우저가 실행을 거부한다. 이건 실제다. 제대로 배포하는 데 드는 상당한 수고를 들일 값어치가 있다.

그런데 CSP가 배포되는 가장 흔한 방식은 script-src'unsafe-inline'을 넣는 것이다. 프론트엔드를 다시 쓰지 않고도 “작동”하게 만드는 게 그거니까. 그리고 'unsafe-inline'은 그 방어의 off 스위치다. 인라인 스크립트야말로 주입된 XSS 페이로드가 사는 곳이다 — 페이지에 반사되는 파라미터, 마크업으로 렌더되는 저장된 댓글. 정책이 인라인 스크립트를 허용하면, 브라우저는 공격자의 <script>를 당신 것과 나란히 기꺼이 실행한다. 정책의 관점에선 허가된 것이니까. 당신에겐 Content-Security-Policy 헤더가 있다. Content-Security-Policy는 없다.

스캐너는 이 둘을 의미 있게 구분하지 못한다. 헤더를 보고, 좋은 스캐너라면 unsafe-inline에 한 점 깎을 수도 있지만, 여전히 “존재함”으로 친다. 그동안 보안 리뷰는 끝났다. 체크리스트가 CSP: 예, 라고 했으니까.

정말로 나를 잠 못 들게 하는 부분은 이거다: 없는 CSP가 이 CSP보다 안전하다. 없는 헤더는 정직한 F다 — XSS 방어가 없다는 걸 모두가 알고, 그건 백로그에 남는다. 있는데 이빨 빠진 CSP는 누구든 볼 모든 리포트에서 “방어 없음”을 “방어 있음”으로 둔갑시킨다. 아무도 초록색 체크를 재감사하지 않는다. 알려진 틈을 통과 점수 뒤에 숨긴 것이다. 이건 중립이 아니다. 아무것도 안 하느니만 못하다.

Report-Only는 정책이 아니다

다음 것은 더 조용하고 그만큼 흔하다. CSP 헤더는 둘이다: 강제하는 Content-Security-Policy, 그리고 아무것도 강제하지 않는 Content-Security-Policy-Report-Only. Report-Only는 프로덕션에서 정책을 시험해 보라고 있는 것이다 — 브라우저가 정책을 평가해 위반 리포트를 보내주지만, 단 하나도 막지 않는다.

정말 좋은 도구다. 그리고 상시로 완성품인 양 배포된다. 팀이 괜찮은 정책을 짜서 안전하게 시험하려고 Report-Only로 배포하고, 일주일 리포트를 지켜보다, 바빠지고, 강제 모드로 끝내 못 뒤집는다. 반년 뒤 그 사이트엔 평생 아무것도 막아본 적 없는 정교하게 튜닝된 CSP가 있다. 헤더는 있다. 스캐너는 유효한 정책을 볼 수도 있다. 그리고 진짜 주입이 그대로 통과하며, 방금 성공한 그 공격에 관한 깔끔한 리포트를 하나 생성한다.

브라우저가 이미 무시하는 화물숭배 헤더

그다음은 고고학이다. 아직도 떠도는 “이 헤더들을 추가하라” 목록의 절반이 X-XSS-Protection: 1; mode=block을 설정하라고 한다. Chrome은 그 헤더가 제어하던 XSS Auditor를 2019년 Chrome 78에서 제거했다. 그 기능은 사라졌다. Firefox는 애초에 구현한 적도 없다. 게다가 존재하던 동안 실제 피해도 냈다: auditor는 원래 없었을 정보 유출을 만들어내도록 조작될 수 있었고, 브라우저가 그걸 걷어낸 이유가 바로 그것이다.

그러니 2026년에 X-XSS-Protection을 설정하는 건 잘해야 현대 브라우저에서 아무것도 안 하고, 못해야 능동적으로 해로웠던 헤더를 화물숭배하는 것이다. 하지만 체크리스트엔 있다. 스캐너는 여전히 찾는다. 도구가 없다고 불평했다는 이유로 이걸 다시 추가하는 팀을 봤다 — 몇 년째 존재하지도 않는 방어의 점수를 쫓으면서.

X-Frame-Options는 같은 이야기의 순한 버전이다. DENYSAMEORIGIN은 여전히 작동하고 여전히 설정할 값어치가 있다. 하지만 특정 부모 오리진 하나를 허용하고 싶을 때 손이 가는 ALLOW-FROM은 현대 브라우저가 전혀 지원하지 않는다 — 설정하면 선택적 프레이밍 보호가 아니라 프레이밍 보호가 아예 없어진다. 진짜 통제는 CSP의 frame-ancestors이고, XFO는 몇 년 전 그것으로 대체됐어야 했다. “X-Frame-Options: 존재함”이라고 말하는 체크리스트는 현대 앱이라면 대부분 넘어섰어야 할 헤더를 채점하고 있는 것이다.

체크리스트가 못 보는 틈

이 하나하나의 모양을 보라. includeSubDomains 없는 HSTS는 apex는 지키고 모든 서브도메인 — 잊힌 스테이징 서버 포함 — 을 SSL 스트리핑에 열어둔다. preload 없는 HSTS는 어떤 헤더도 아직 본 적 없는 맨 첫 방문을 지키지 못하는데, 그 순간이야말로 능동 공격자가 노리는 지점이다. 설정 안 한 Permissions-Policy는 기능을 잠그는 게 아니라 브라우저의 관대한 기본값을 그대로 둔다. 매번 헤더는 “존재”하면서 그 이름이 암시하는 것의 일부만, 혹은 아무것도 안 할 수 있다.

스캐너는 응답 헤더 위에서 동작한다. 당신의 애플리케이션은 보지 못한다. 당신의 CSP가 프론트엔드가 실제로 스크립트를 로드하는 방식과 맞는지, Report-Only가 승격되긴 했는지, 그 프레이밍 정책이 진짜 위협 모델을 반영하는지 아니면 복붙한 스니펫인지 모른다. 봉투를 채점한다. 보안은 편지에 있다.

여기서 조심하고 싶다. 쉬운 오독이 “헤더는 중요하지 않으니 건너뛰라”이기 때문이다. 그게 아니다. 헤더는 엄청나게 중요하다 — 올바른 CSP는 당신이 배포할 수 있는 가장 레버리지 높은 것 중 하나고, 나라면 제대로 배포할 수고를 위해 싸우겠다. 문제는 헤더가 아니다. 일을 대신하는 체크리스트다. “보안 헤더를 설정하는가”는 도구가 답할 수 있는 질문이다. “우리 CSP가 우리 앱에서 주입된 스크립트의 실행을 실제로 막는가”는 정책과 앱을 둘 다 이해하는 사람만 답할 수 있는 질문이고, 애초에 물어볼 값어치가 있던 유일한 질문이다.

통과가 실제로 사주는 것

그래서 — 정확히 이런 헤더 검사를 출력하는 도구를 만드는 사람으로서 — 내가 도달한 지점은 이렇다. 초록색 헤더 스캔은 통과한 린터처럼 대하라: 명백한 기계적 실수를 안 했다는 뜻이다. 코드가 올바르다는 뜻은 아니고, 린트가 깨끗하다고 기능이 작동한다고 믿는 멀쩡한 사람은 없다.

당신의 CSP를 직접 읽고 경로 하나를 따라가 보라: 공격자가 이 페이지에 <script>를 올렸다면, 무엇이 그걸 막는가? 답에 unsafe-inline이 끼어 있으면, 답은 아무것도 없다, 이다. 정책이 강제 중인지 리포트 중인지 확인하라. 브라우저가 더는 존중하지 않는 헤더는 도구 만족시키려 다시 넣지 말고 지워라. 그리고 누가 “보안 헤더 추가”라는 티켓을 닫거든, 값을 직접 읽어보라 — 진짜 방어와 장식용 방어의 차이는 체크리스트에서 보이지 않고, 그 차이가 중요한 전부이기 때문이다.

A+는 결승선이 아니다. 진짜 리뷰가 시작되는 지점이다.

토론 참여

← 블로그로 돌아가기