짧은 답

HTTPS로 서비스하는 일반적인 웹사이트라면 다음 응답 헤더 조합이 무난한 출발점입니다.

Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: (사이트에 맞게 작성, 아래 참고)
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

이 중 네 개는 대개 그대로 써도 됩니다. HSTS는 단계적으로 올려야 하고 서브도메인을 먼저 살펴봐야 합니다. CSP는 사이트에 맞게 직접 써야 합니다. 남의 정책을 복사해 붙이는 것이 보안 헤더 때문에 잘 돌던 사이트가 깨지는 가장 흔한 경로입니다.

이 헤더들은 취약점을 고쳐 주지 않습니다. 사이트가 원래 하지 말아야 할 일, 즉 평문 HTTP로 로드되거나, 예상하지 못한 곳의 스크립트를 실행하거나, 남의 프레임 안에 그려지는 일을 브라우저가 거부하게 만들 뿐입니다. 실제 버그가 있을 때 공격자가 그걸로 할 수 있는 일을 줄여 줍니다.

지금 보내고 있는 헤더 확인

curl -s -D - -o /dev/null https://example.com/ \
  | grep -iE '^(strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy)'

일반 GET 요청을 보내고 응답 헤더만 출력합니다. curl -I는 HEAD 요청을 보내는데, 대부분의 서버는 똑같이 응답하지만 그렇지 않은 애플리케이션도 있어서 GET으로 확인하는 편이 안전합니다.

홈페이지 하나만 보지 마세요.

  • 오류 페이지(https://example.com/does-not-exist). nginx에서 always 없이 추가한 헤더는 4xx·5xx 응답에서 빠집니다.
  • 두 호스트명 모두, example.com과 www.example.com. 서로 다른 server 블록에서 처리되는 경우가 많습니다.
  • 별도 location으로 처리되는 경로, 예를 들어 /api/나 /static/. 이유는 아래에서 설명합니다.

HTTP 점검은 같은 일을 바깥에서 합니다. 리다이렉트를 따라가 최종 응답의 헤더를 읽고 등급을 매깁니다.

DechoNet이 이 헤더들을 채점하는 방식

등급은 헤더 여섯 개, 총 100점으로 매깁니다.

헤더배점약함(절반 점수)으로 보는 경우
Content-Security-Policy25스크립트를 제한하지 않음: script-src·default-src가 없음(예: frame-ancestors만 있음), 와일드카드나 https: 출처, 'unsafe-eval', nonce·hash·'strict-dynamic' 없는 'unsafe-inline'
Strict-Transport-Security20max-age가 31536000(1년) 미만
X-Frame-Options15
X-Content-Type-Options15
Referrer-Policy15값이 정확히 origin
Permissions-Policy10

여기에 Server, X-Powered-By, X-AspNet-Version, X-AspNetMvc-Version 헤더가 하나 있을 때마다 값과 상관없이 3점씩 뺍니다. 등급은 95점 이상 A+, 85점 이상 A, 70점 이상 B, 55점 이상 C, 40점 이상 D, 그 아래는 F입니다.

COOP, CORP, COEP, X-XSS-Protection은 결과에 표시되지만 배점이 없습니다. 교차 출처 격리는 일부 앱에만 필요하고, COEP는 평범한 페이지를 깨뜨릴 수 있기 때문입니다. X-XSS-Protection 값이 0이면 목록에 partial로 표시되는데, 등급에는 영향이 없고 0은 오히려 맞는 값입니다(아래 참고).

등급이 알려 주지 못하는 것도 알아 두세요. 등급은 헤더가 있는지, 그리고 몇 가지 뻔한 약점이 있는지만 봅니다. CSP가 실제로 무언가를 제한하는지는 판단하지 않습니다. Content-Security-Policy: frame-ancestors 'self'는 스크립트에 대해 아무것도 하지 않지만 25점을 다 받습니다. 또 적용 중인 Content-Security-Policy 응답 헤더만 세므로, Report-Only 정책이나 <meta> 태그 정책은 없는 것으로 나옵니다.

Content-Security-Policy

CSP는 스크립트, 스타일, 이미지, 프레임, 폼 전송 대상 같은 리소스 종류마다 어디서 가져와도 되는지를 브라우저에 알려 줍니다. 주된 역할은 크로스 사이트 스크립팅(XSS)의 피해를 줄이는 것입니다. 공격자가 페이지에 <script>를 끼워 넣는 데 성공해도, 좋은 정책이 있으면 브라우저가 그것을 실행하지 않습니다.

CSP가 사이트를 깨뜨리는 이유는 요즘 페이지가 여러 곳의 코드를 실행하기 때문입니다. 인라인 <script> 블록, onclick= 속성, 스크립트를 더 불러오는 분석 도구와 태그 매니저, 채팅 위젯, A/B 테스트 도구, eval을 쓰는 오래된 라이브러리 등입니다. 정책에 이 중 하나라도 빠져 있으면 조용히 차단되고, 흔적은 브라우저 콘솔에만 남습니다.

먼저 Report-Only로 내보내기

Report-Only 정책은 브라우저가 평가만 하고, 막았을 것들을 보고만 할 뿐 실제로는 아무것도 막지 않습니다.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://www.googletagmanager.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; report-uri /csp-report; report-to csp
Reporting-Endpoints: csp="https://example.com/csp-report"

report-uri는 report-to와 Reporting-Endpoints 헤더로 대체되는 중이라 폐기 예정(deprecated)이지만, 새 방식의 브라우저 지원이 고르지 않아서 대부분의 사이트가 둘 다 보냅니다. 보고서는 엔드포인트로 JSON POST 요청이 옵니다. 잡음이 많을 것을 각오하세요. 브라우저 확장 프로그램이 페이지에 스크립트를 넣기 때문에 chrome-extension://이나 moz-extension:// 출처의 위반이 섞여 들어옵니다. 이것들을 걸러 내고 남는 것을 보면 됩니다.

Report-Only는 모든 종류의 페이지를 거칠 만큼 충분히 오래 돌리세요. 테스트할 때 아무도 들어가지 않는 페이지까지 포함해야 합니다. 결제, 비밀번호 재설정, 관리자 화면, 지난 분기에 마케팅팀이 만든 캠페인 랜딩 페이지 같은 곳입니다. 보고서에 확장 프로그램 잡음만 남으면 헤더 이름을 Content-Security-Policy로 바꿉니다. 적용 중인 정책 옆에 더 엄격한 후보 정책을 Report-Only로 함께 보낼 수도 있습니다. 브라우저는 두 헤더를 동시에 처리합니다.

Report-Only 정책은 <meta> 태그로 설정할 수 없습니다. 적용(enforce) 정책은 meta로도 되지만, meta 정책은 frame-ancestors, report-uri, sandbox를 무시합니다. 응답 헤더를 쓰세요.

허용 목록과 nonce

호스트 허용 목록으로 만든 정책(script-src 'self' https://cdn.example.net https://www.google.com)은 쓰기 쉽지만 보기보다 약합니다. 대형 CDN이나 일부 대형 플랫폼 도메인은 공격자가 재활용할 수 있는 스크립트, 예를 들어 오래된 버전의 라이브러리나 JSONP 엔드포인트를 제공합니다. 그래서 호스트 전체를 허용하면 주입된 스크립트가 그 틈으로 통과할 수 있습니다.

더 강한 방식은 nonce입니다. 서버가 응답마다 새 무작위 값을 만들어 헤더에 넣고, 신뢰하는 <script> 태그마다 같은 값을 붙입니다.

Content-Security-Policy: script-src 'nonce-k8Fq2xR7pLm3' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="k8Fq2xR7pLm3" src="/app.js"></script>

'strict-dynamic'을 쓰면 신뢰한 스크립트가 불러온 스크립트도 신뢰하므로 태그 매니저가 계속 태그를 불러올 수 있고, 이를 지원하는 브라우저는 script-src의 호스트 허용 목록을 무시합니다. nonce나 해시가 있으면 CSP Level 2 이상 브라우저는 script-src의 'unsafe-inline'을 무시합니다. 그래서 아주 오래된 브라우저를 위한 대비책으로 'unsafe-inline'을 함께 넣는 정책도 있습니다.

문제는 캐시입니다. nonce는 예측할 수 없어야 하고 응답마다 달라야 합니다. CDN이 HTML을 캐시하면 모든 방문자가 같은 nonce를 받게 되어 아무것도 지켜 주지 못합니다. 정적이거나 캐시되는 페이지에는 인라인 스크립트의 해시('sha256-…')를 쓰세요. 대부분의 빌드 도구가 만들어 줍니다.

어떤 정책에든 넣을 만한 지시어가 두 개 있습니다. object-src 'none'(플러그인 차단)과 base-uri 'self' 또는 'none'(주입된 <base> 태그가 상대 경로 스크립트 주소를 바꾸지 못하게 함)입니다. HTTPS 사이트에서 HTTP 리소스를 정리하는 중이라면 upgrade-insecure-requests가 브라우저에게 그 리소스를 HTTPS로 가져오게 합니다. 혼합 콘텐츠를 참고하세요.

채점에 관해 한마디 하면, DechoNet은 스크립트 출처만 봅니다(script-src, 없으면 default-src). style-src의 'unsafe-inline'은 감점하지 않습니다. 인라인 스타일은 인라인 스크립트보다 위험이 훨씬 작기 때문입니다. nonce나 hash와 함께 있는 'unsafe-inline'도 브라우저가 무시하므로 세지 않습니다. frame-ancestors만 정한 정책은 둘 만한 가치가 있지만 스크립트 주입은 막지 못하므로 절반 점수입니다.

Strict-Transport-Security(HSTS)

HSTS는 브라우저에게 “앞으로 max-age초 동안 이 호스트는 HTTPS로만 접속하고, 인증서 오류가 나도 사용자가 무시하고 들어가지 못하게 하라”고 알려 줍니다. 사용자가 example.com을 입력했을 때 브라우저가 먼저 평문 HTTP로 시도하고, 네트워크에 있는 공격자가 그 첫 요청을 가로채는 틈을 막습니다.

설정하기 전에 알아 둘 것이 있습니다.

  • 평문 HTTP로 받은 HSTS 헤더는 브라우저가 무시합니다. HTTPS 응답에 있어야만 효력이 있습니다.
  • 브라우저가 아직 헤더를 본 적이 없으므로 첫 방문은 보호되지 않습니다. 이것은 preload로 해결합니다. HSTS preload 체크리스트를 참고하세요.
  • 빨리 되돌릴 수 없습니다. max-age=31536000을 본 브라우저는 1년 동안 HTTPS를 고집합니다. 철회하려면 max-age=0을 보내고, 방문자가 다시 와서 그 헤더를 받을 때까지 기다려야 합니다.

그래서 단계적으로 올립니다. max-age=300으로 시작해 하루, 일주일, 한 달을 거쳐 마지막에 1년으로 올리고, 단계마다 HTTP로만 서비스되는 무언가가 아직 필요하지 않은지 확인합니다.

includeSubDomains는 이 규칙을 모든 서브도메인에 적용합니다. 최종적으로는 이렇게 하는 것이 맞고, 바로 그 때문에 사고가 납니다. 내 도메인 아래의 인트라넷 호스트, 프린터 관리 화면, HTTP만 되는 오래된 호스트명이, 헤더를 본 브라우저에서는 접속할 수 없게 됩니다. 먼저 목록을 만드세요. 서브도메인 조회는 인증서 투명성(CT) 로그에 나타난 이름을 보여 줍니다. 공인 인증서를 받은 적 없는 내부 이름은 거기 없으니, 내부 DNS 담당자에게 확인해야 합니다.

DechoNet은 max-age가 1년 미만이면 약함(20점 중 10점)으로 봅니다. 헤더는 리다이렉트를 따라간 최종 페이지에서 읽습니다. preload를 하려면 루트 도메인의 HTTPS 응답에도 헤더가 있어야 하고, 그 응답이 www로 보내는 리다이렉트일 뿐이어도 마찬가지입니다. 그러니 루트 도메인은 직접 확인하세요.

curl -sI https://example.com/ | grep -i strict-transport

X-Frame-Options와 frame-ancestors

둘 다 다른 사이트가 내 페이지를 프레임 안에 불러오지 못하게 합니다. 클릭재킹(clickjacking)은 내 진짜 페이지를 미끼 페이지 아래에 투명하게 깔아 두고, 사용자의 클릭이 내 버튼에 떨어지게 만드는 공격입니다.

X-Frame-Options에서 쓸모 있는 값은 DENY와 SAMEORIGIN 두 가지입니다. 세 번째 값 ALLOW-FROM은 현재 브라우저가 지원하지 않으며, 이 값을 쓴 헤더는 없는 것처럼 취급됩니다.

CSP의 frame-ancestors가 현대적인 대체 수단이고, 특정 사이트를 지정할 수 있습니다.

Content-Security-Policy: frame-ancestors 'self' https://partner.example.org

둘 다 있으면 frame-ancestors를 지원하는 브라우저는 그것을 따르고 X-Frame-Options를 무시합니다. 그래서 결제 위젯이나 학습 플랫폼처럼 파트너가 내 페이지를 정당하게 임베드한다면, 파트너를 frame-ancestors에 넣고 오래된 클라이언트용으로 X-Frame-Options: SAMEORIGIN을 유지하면 됩니다. 현대 브라우저는 파트너를 허용하고, 아주 오래된 브라우저는 막습니다. 대개 받아들일 만한 결과입니다. frame-ancestors는 응답 헤더로만 동작하고 meta 태그에서는 동작하지 않습니다.

X-Content-Type-Options

유효한 값은 nosniff 하나뿐입니다. 브라우저가 파일 내용을 보고 종류를 추측하지 못하게 하고, Content-Type이 맞지 않는 스크립트나 스타일시트를 거부하게 합니다.

사이트가 깨지는 경우는 두 번째 동작 때문뿐입니다. 서버가 JavaScript를 text/plain이나 text/html로 보내고 있다면, 헤더를 넣는 순간 그 스크립트는 실행되지 않습니다. 배포 전에 확인하세요.

curl -sI https://example.com/assets/app.js | grep -i content-type

스크립트는 text/javascript나 application/javascript, 스타일시트는 text/css여야 합니다.

Referrer-Policy

링크를 따라가거나 페이지가 다른 곳의 리소스를 불러올 때, 브라우저가 Referer 헤더에 현재 URL을 얼마나 담아 보낼지 정합니다.

현재 브라우저는 정책이 없으면 이미 strict-origin-when-cross-origin이나 그보다 엄격한 동작을 기본으로 합니다. 내 사이트로는 전체 URL을, 다른 사이트로는 출처(origin)만 보내고, HTTPS에서 HTTP로 갈 때는 아무것도 보내지 않습니다. 명시적으로 보내면 브라우저 기본값과 상관없이 이 동작이 고정됩니다. URL에 비밀번호 재설정 토큰, 내부 검색어, 환자 식별자처럼 민감한 것이 담긴다면 same-origin이나 no-referrer를 쓰세요. 대신 파트너 사이트는 유입 경로를 더는 알 수 없게 됩니다.

unsafe-url과 no-referrer-when-downgrade는 쿼리 문자열까지 포함한 전체 URL을 다른 사이트로 보내므로 피하세요. DechoNet은 origin을 약함으로 보는데, HTTPS에서 HTTP로 갈 때도 출처를 보내기 때문입니다.

Permissions-Policy

내 사이트가 쓰지 않는 브라우저 기능을 내 페이지와 그 안의 프레임에서 꺼 둡니다.

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()

()는 아무도 쓸 수 없음, (self)는 내 출처만, (self "https://maps.example.com")은 특정 임베드 사이트를 추가한다는 뜻입니다. 예전 Feature-Policy 헤더를 대체했고, 그 헤더는 문법이 달랐습니다(camera 'none'). 헤더 지원은 Chromium 계열 브라우저가 가장 충실합니다. Chrome은 남아 있는 interest-cohort=()처럼 모르는 기능 이름이 있으면 콘솔에 경고를 남깁니다. 해롭지는 않지만 지워도 됩니다.

임베드가 필요로 하는 기능은 끄지 마세요. 결제 대행사의 iframe은 payment가, 화상 통화 위젯은 camera와 microphone이 필요할 수 있습니다. 그런 프레임은 <iframe> 태그에 해당 allow= 속성도 있어야 합니다.

X-XSS-Protection은 빼세요

이 헤더는 예전 브라우저에 내장된 XSS 필터를 제어했습니다. 현재 주요 브라우저에는 그 필터가 남아 있지 않습니다. 필터가 은퇴한 데에는 필터 자체가 문제를 일으켰다는 이유도 있습니다. 공격자가 필터를 일부러 작동시켜 페이지의 정상 스크립트를 끄거나, 사이트 간에 정보를 새게 할 수 있었습니다.

새 사이트에 X-XSS-Protection: 1; mode=block을 보내지 마세요. 헤더를 아예 빼거나, 필터가 남아 있는 오래된 브라우저에서 필터를 끄도록 X-XSS-Protection: 0을 보내면 됩니다. XSS 방어는 출력 인코딩과 CSP가 맡습니다.

헤더 설정하기

nginx

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    server_tokens off;
}

거의 모든 사람이 걸리는 함정이 두 개 있습니다.

always. 이것이 없으면 nginx는 성공 응답과 리다이렉트 응답에만 헤더를 붙입니다. 404, 500 페이지는 이 헤더 없이 나갑니다.

상속. 자체 add_header가 하나라도 있는 location 블록은 server 블록의 add_header를 하나도 물려받지 않습니다. location /static/에 Cache-Control 헤더 하나를 추가하면 그 경로 전체에서 보안 헤더가 조용히 사라집니다. 보통은 보안 헤더를 스니펫 파일로 빼서 server 블록과, 자체 add_header가 있는 모든 location에서 include하는 방식으로 해결합니다. nginx가 실제로 어떤 설정으로 돌고 있는지는 이렇게 봅니다.

sudo nginx -T 2>/dev/null | grep -nE 'server_name|location|add_header|include'

nginx 뒤의 애플리케이션이 같은 헤더를 보내면 헤더가 두 번 나갑니다. CSP 헤더가 두 개면 둘 다 적용되므로 실제 정책은 각각보다 같거나 더 엄격해집니다. 다른 헤더가 두 번 나가면 해석이 모호해질 뿐입니다. 헤더마다 설정할 곳을 한 군데로 정하고, nginx가 맡아야 한다면 proxy_hide_header Content-Security-Policy;로 애플리케이션 쪽 헤더를 지우세요.

Apache

mod_headers를 켠 상태에서(Debian·Ubuntu는 sudo a2enmod headers) 다음과 같이 씁니다.

<IfModule mod_headers.c>
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
</IfModule>

HSTS는 :443 가상 호스트에 넣으세요. AllowOverride가 허용한다면 .htaccess에서도 동작합니다. 이 헤더들은 Header always set으로 통일하세요. Apache는 always용과 일반용 헤더 목록을 따로 관리하기 때문에, 같은 헤더를 두 방식으로 설정하면 중복이 생기기 쉽습니다. 리로드 전에 apachectl configtest를 돌리세요.

Cloudflare

  • HSTS: SSL/TLS → Edge Certificates → HTTP Strict Transport Security. Cloudflare가 엣지에서 헤더를 붙입니다.
  • 나머지 헤더: Rules 아래의 응답 헤더 Transform Rule에서 헤더마다 고정 값을 지정합니다. 전체 요청이나 특정 호스트명에 적용할 수 있습니다. Cloudflare에는 기본 보안 헤더 묶음을 붙여 주는 관리형 변환(managed transform)도 있는데, 의존하기 전에 정확히 어떤 헤더를 보내는지 확인하세요.
  • Cloudflare Pages: 빌드 결과물에 _headers 파일을 둡니다.
/*
  X-Frame-Options: SAMEORIGIN
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

원 서버도 헤더를 보내고 Cloudflare도 보낸다면, 방문자가 실제로 어떤 값을 받는지 curl로 확인하세요.

정보 노출 헤더

DechoNet은 Server, X-Powered-By, X-AspNet-Version, X-AspNetMvc-Version 헤더가 있을 때마다 3점씩 뺍니다. 실제 위험은 버전 번호에 있습니다. 버전이 보이면 공격자가 탐색 없이도 알려진 취약점과 맞춰 볼 수 있습니다. 제품 이름만으로는 알려 주는 것이 별로 없습니다.

버전을 지우는 것은 간단합니다.

  • nginx: server_tokens off;(Server: nginx는 남음)
  • Apache: ServerTokens Prod와 ServerSignature Off(Server: Apache는 남음)
  • PHP: php.ini에 expose_php = Off로 X-Powered-By 제거
  • Express: app.disable('x-powered-by')
  • ASP.NET: <httpRuntime enableVersionHeader="false" />로 X-AspNet-Version 제거

Server 헤더를 완전히 없애는 것은 기본 nginx나 Apache로는 안 되고, nginx라면 headers-more 같은 추가 모듈이 필요합니다. CDN 뒤에 있다면 이 헤더는 대개 CDN 이름을 담고 있는데, 공격자가 DNS만 봐도 알 수 있는 정보입니다. 버전 숨기기는 할 만한 일이고, 마지막 3점을 쫓는 것은 선택입니다.

바꾼 다음에

  1. 홈페이지, 오류 페이지, 두 호스트명, 별도 설정이 있는 경로마다 curl 명령을 다시 돌리세요.
  2. 브라우저 콘솔을 열어 둔 채 주요 흐름을 클릭해 보세요. CSP와 nosniff 문제는 거기서 가장 먼저 보입니다.
  3. HTTP 점검으로 리다이렉트 이후의 결과를 바깥에서 확인하세요.
  4. CSP를 Report-Only에서 적용으로 바꾼 뒤에도 보고 엔드포인트를 유지하고 최소 일주일은 지켜보세요.

헤더를 손볼 때는 캐시 설정도 함께 보는 것이 좋습니다. 같은 add_header 상속 규칙이 거기에도 적용되기 때문입니다. 속도·캐시·압축 체크리스트를 참고하세요.

내 도메인에서 바로 확인

무료, 가입 불필요. 이 가이드가 다루는 항목을 바로 검사하고 조치 방법을 알려드립니다.