diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c0b7597..7639ab3 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; + 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; + 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;