# ========================================== # 프론트엔드 nginx — 화면(SPA) 정적 호스팅 + /api 를 백엔드로 넘기는 프록시 # # 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말). # 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다. # # ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 nginx 이미지의 entrypoint 가 이 파일 # (/etc/nginx/templates/default.conf.template)의 ${NGINX_*} 만 envsubst 로 채워 /etc/nginx/conf.d/default.conf 를 만든다. # 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 등)에서 계산한다. # nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER). # ========================================== # 접근 로그 — 쿼리를 남기지 않는다. 검색어(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; } 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; # 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다 access_log /var/log/nginx/access.log main_rid if=$log_access; # 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다 client_max_body_size 1m; # ---------------------------------------- # 보안 헤더 (화면용) # # nginx 는 하위 블록에 add_header 가 하나라도 있으면 상위 블록의 add_header 를 물려주지 않는다. # (정적 파일 location 의 Cache-Control 때문에 JS·CSS 응답에 아래 헤더가 모두 빠져 있었다) # merge 로 상위 것과 합친다 — nginx 1.29.3 이상 (nginx-unprivileged:stable-alpine 은 1.30). # /api 는 아래에서 상속을 끈다 — API 응답의 보안 헤더는 백엔드(helmet)가 정한다. # ---------------------------------------- add_header_inherit merge; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" 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 방어의 핵심 헤더 # # ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명: # - default-src 'self' : 기본적으로 같은 출처만 허용 # - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데, # 그 안에는 앱이 넣는