본문으로 건너뛰기
개발 머꼬
개발 노트Nginx
hohyeon.dev2

location 하나에 헤더를 추가했더니 보안 헤더가 전부 사라진 이유

  • #Engineering Note
  • #Nginx
  • #Security

문제 발생

보안 헤더는 server 블록에 모아 두고 있었습니다. 위에 적어 두면 모든 응답에 붙을 거라고 생각했으니까요.

server {
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }

    location /uploads/ {
        alias /srv/meokko/uploads/;
        add_header Cache-Control "public, max-age=86400";
    }
}

업로드 이미지에 캐시 헤더를 한 줄 붙였을 뿐인데, 며칠 뒤 curl -I로 이미지 응답을 보니 Strict-Transport-Security와 X-Content-Type-Options가 통째로 빠져 있었습니다. 페이지 응답에는 둘 다 멀쩡히 붙어 있었는데 말입니다.

원인 분석

add_header는 위에서 물려받은 목록에 한 줄을 더하는 지시자가 아니었습니다. nginx 문서의 문장이 그대로입니다 — 이 지시자들은 현재 레벨에 add_header가 하나도 없을 때에만 이전 설정 레벨에서 상속됩니다. location /uploads/에 add_header를 한 줄이라도 적는 순간, server에 적어 둔 헤더는 그 location에서 전부 버려집니다.

location /에는 add_header가 없어서 그대로 상속을 받았고, /uploads/는 캐시 헤더 한 줄 때문에 상속이 끊긴 겁니다. 설정 파일을 위에서부터 읽으면 "위에 적었으니 다 붙겠지"로 보여서 더 놓치기 쉬웠습니다.

always를 빼먹으면 구멍이 하나 더 생깁니다. 문서에 따르면 add_header는 응답 코드가 200, 201, 204, 206, 301, 302, 303, 304, 307, 308일 때만 헤더를 붙입니다. always를 줘야 응답 코드와 상관없이 붙습니다. 그러니 always 없이 적은 헤더는 404나 500 응답에는 처음부터 없었던 셈입니다.

해결 방안

  1. 헤더를 다시 적는 location에는 공통 헤더도 다시 넣습니다. 손으로 반복하면 언젠가 하나가 빠지니, 공통 헤더를 파일로 빼서 include합니다.
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
location /uploads/ {
    alias /srv/meokko/uploads/;
    include snippets/security-headers.conf;
    add_header Cache-Control "public, max-age=86400";
}
  1. nginx 1.29.3 이상이라면 add_header_inherit merge를 씁니다. 이 버전에서 상속 규칙을 바꾸는 add_header_inherit가 들어왔습니다. 기본값은 on으로 지금까지와 같은 동작이고, merge는 이전 레벨의 값을 현재 레벨의 값에 덧붙이고, off는 상속을 아예 끊습니다.
location /uploads/ {
    alias /srv/meokko/uploads/;
    add_header_inherit merge;
    add_header Cache-Control "public, max-age=86400";
}

nginx -v로 버전부터 확인합니다. 그보다 낮은 버전에서는 이 지시자를 모르니 1번 방식으로 갑니다.

  1. 보안 헤더에는 always를 붙입니다. 에러 페이지도 브라우저가 받아서 해석하는 응답입니다. 2xx·3xx에만 헤더가 붙는 기본 동작을 알고 나면 빼먹을 이유가 없습니다.

  2. 확인은 location마다 합니다. 대표 페이지 하나만 보면 이 문제는 절대 안 보입니다. location 블록마다 경로를 하나씩 고르고, 일부러 404가 나는 경로도 하나 넣어서 같이 찍어 봅니다.

for p in / /uploads/a.png /no-such-page; do
  echo "== $p"
  curl -sI "https://example.com$p" | grep -iE "strict-transport|x-content-type"
done

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.