feat(docker): 접근 로그에서 쿼리 빼기·Referrer-Policy strict-origin

검색어가 화면 주소(/mail/search?q=…)에도 들어가서 화면 요청과 Referer 까지 접근 로그에서 쿼리를 뺐다.
Referer 로 검색어가 실리지 않게 화면의 Referrer-Policy 를 strict-origin 으로 둔다. 성공한 1분 확인은 접근 로그를 남기지 않는다(폴링).
휴지통 영구 삭제는 엔진을 여러 번 기다리므로 발송·임시 저장과 같은 90s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 23:56:10 +09:00
parent 192807f943
commit 5b6a97e5e1
+50 -15
View File
@@ -10,20 +10,46 @@
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
# ==========================================
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
log_format main_rid '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
# 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다
# (계약 2절 "검색"·"받는 사람 추천", 로깅 규칙). 형식은 main_rid 하나이고 server 단계에만 건다 — 모든 location
# (/api·오류용 이름 있는 location 포함)과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400 등)이 물려받는다.
# - 요청 줄: $request 에서 ? 부터 다음 빈칸 앞까지 뺀다 ("GET /mail/search?q=… HTTP/1.1" → "GET /mail/search HTTP/1.1").
# $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다.
# - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도
# origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다.
# - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다).
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
map $request $log_request_line {
"" "-";
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
default $request;
}
# /api 용 — main_rid 와 같되 요청 줄에서 쿼리를 뺀다: 검색어(q) 등이 요청마다 남지 않게 (계약 2절 "받는 사람 추천" — 로그에는 개수만)
log_format api_rid '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
map $http_referer $log_referer {
"" "-";
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
default $http_referer;
}
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
map "$request_method:$uri:$status" $log_access {
"~^GET:/api/mail-state:(?:2[0-9][0-9]|304)$" 0;
default 1;
}
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent '
'"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
server {
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
listen 8080;
server_name localhost;
access_log /var/log/nginx/access.log main_rid;
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
access_log /var/log/nginx/access.log main_rid if=$log_access;
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
client_max_body_size 1m;
@@ -39,7 +65,11 @@ server {
add_header_inherit merge;
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;
# Referer 에는 같은 주소의 요청에도 origin 만 싣는다 (strict-origin) — 화면 주소에 검색어(/mail/search?q=…)와
# 임시 저장 id(?draft=)가 들어가서, 주소째 보내면 그 화면에서 나가는 요청마다 nginx 오류 로그·외부 리버스 프록시 로그에 남는다.
# Referer 를 읽는 코드는 없다 — 백엔드 출처 검사(OriginGuard)는 Sec-Fetch-Site·Origin 을 보고, 같은 주소 HTTPS 요청의 Origin 은
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (origin 만, HTTPS → HTTP 는 보내지 않는다).
add_header Referrer-Policy "strict-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content-Security-Policy — XSS 방어의 핵심 헤더
@@ -136,8 +166,6 @@ server {
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
add_header_inherit off;
proxy_pass $backend_upstream;
# 쿼리 없는 로그 형식 — 하위 location 은 물려받는다. 오류로 넘어가는 이름 있는 location(아래 @api_*)은 따로 적는다
access_log /var/log/nginx/access.log api_rid;
# nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식(JSON)으로 답한다 — 계약 머리말 "프록시가 직접 내는 오류"
# (= 없이 쓰면 상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 proxy_intercept_errors 가 꺼져 있어 손대지 않는다)
@@ -176,13 +204,22 @@ server {
# 메일 발송(POST /api/messages)은 JSON 본문 4MB — 서식 본문·긴 인용 때문 (계약 2절 413 표, 백엔드도 이 경로와 아래 임시 저장만 4MB 파서).
# 계약이 정한 고정값이라 env 로 바꾸지 않는다 (나머지 /api 의 1m 도 백엔드 JSON_BODY_LIMIT 고정값과 같다 — 값이 같아 여유를 두지 않는다).
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없다.
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없고,
# 여러 통 PATCH(계약 2절 "여러 통 한꺼번에")는 본문이 작아(id 100개까지) 한도와 상관없다. 메서드는 가리지 않는다.
location = /api/messages {
client_max_body_size 4m;
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 여러 통 영구 삭제(POST /api/message-deletions, 계약 2절 "여러 통 한꺼번에") — 백엔드가 메일 엔진에 확인한 뒤 조건부로 지우고,
# 어긋나면 다시 확인한다(합계 CONDITIONAL_DESTROY_ATTEMPTS). 엔진 응답을 여러 번 기다리므로 /api/messages·/api/drafts 와 같은 90s.
# 본문 한도는 기본 1m 그대로다 (id 100개까지)
location ^~ /api/message-deletions {
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 메일 임시 저장 — 처음 저장(POST /api/drafts)·바꿔 저장(PUT /api/drafts/{id})도 JSON 본문 4MB. 본문이 발송과 같은 필드·한도라서다
# (계약 2절 413 표·"임시 저장", 백엔드도 이 두 요청은 발송과 같은 4MB 파서). 발송과 같은 이유로 env 로 바꾸지 않는다.
# 조회(GET /api/drafts/{id})는 본문이 없어 같은 블록에 둔다. 저장도 메일 엔진에 만들고·다시 읽고·옛것을 지우는 요청이 이어져 90s 를 기다린다
@@ -194,12 +231,11 @@ server {
}
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끄고 로그 형식(api_rid)도 다시 정한다
# (내부 이동 뒤에는 이 location 의 access_log 가 쓰인다).
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
location @api_payload_too_large {
add_header_inherit off;
access_log /var/log/nginx/access.log api_rid;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
@@ -209,7 +245,6 @@ server {
location @api_unavailable {
add_header_inherit off;
access_log /var/log/nginx/access.log api_rid;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;