feat(docker): 대용량 첨부 — ClamAV 검사기 · 공개 내려받기 호스트 · 받기 동시 연결 상한

- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용),
  freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav.
  clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README).
  검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다.
- nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다).
  조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k,
  limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200.
  받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다).
  접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다.
- 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다.
- CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부.
- mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:21 +09:00
parent c5cc1a6705
commit 93147b88b7
19 changed files with 1176 additions and 103 deletions
+285 -90
View File
@@ -4,10 +4,27 @@
# 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말).
# 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다.
#
# server 가 둘이다 (11단계 — 대용량 첨부의 공개 내려받기, 계약 4절 "공개 호스트"). 어느 쪽인지는 Host 로 가른다 —
# 외부 리버스 프록시는 두 호스트를 모두 이 8080 으로 넘기며 Host 를 그대로 둔다 (mail/README.md "Nginx Proxy Manager 설정"):
# 포털 default_server — 웹메일 화면과 /api. 공개 내려받기(/s/ · /api/public-links/)는 공개 호스트를 따로 두면 404 로 닫는다
# 공개 내려받기 SHARE_PUBLIC_URL 의 호스트 이름 — /s/<토큰>(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404
# 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh
#
# ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다.
# 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은
# 대소문자를 가리지 않는 정규식(~*)으로 /api/ 블록 안에 둔다 — 대소문자를 가리는 prefix location(^~ /api/messages 등)에 기대면 변형이 비켜 간다
# (2026-10-30 리뷰 — 받기 limit_conn 을 /FILES/ · /Messages/ 로 비켜 갔다). 백엔드의 대소문자 구분(겹 방어)에는 기대지 않는다.
# /API/… 처럼 /api/ 자체가 다르면 /api/ 블록에 들지 않아 백엔드로 가지 않는다(포털은 SPA 화면, 공개 내려받기는 404).
#
# ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 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-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB·SHARE_PUBLIC_URL·CORS_ORIGIN 등)에서 계산한다.
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
# 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다):
# share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다)
# share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다)
# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*)
# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다.
# ==========================================
# 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다
@@ -17,20 +34,53 @@
# $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다.
# - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도
# origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다.
# - 내려받기 토큰도 가린다 (11단계 — 계약 4절 "로그"). 토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 요청 줄 · Referer 둘 다,
# 두 가지로 겹쳐 가린다:
# (1) 경로 모양 — /s/<…> · /api/public-links/<…> 의 그 칸을 길이와 상관없이 ***(대소문자 무시). 잘린 토큰(몇 글자 모자란 것)도 가린다
# (2) 토큰 모양 — 토큰은 base64url 43자다(backend modules/file-sharing/share-token.ts). 앞뒤가 [A-Za-z0-9_-] 가 아닌 딱 43자 덩어리,
# 그리고 %XY(퍼센트 인코딩) 바로 뒤의 43자 덩어리를 *** 로 — 경로 모양이 어긋나도(//s/T · /s//T · /./s/T · /%73/T · /x/../s/T ·
# /api//public-links/T · /api/public%2Dlinks/T · /api/public-links%2FT — nginx 는 정규화해 받지만 로그는 받은 그대로다) 가린다.
# 43자가 아닌 값(UUID 36자 · 메일 id · 엔진의 첨부 id 처럼 더 긴 것)은 가리지 않는다. 요청 줄은 두 번 본다(한 줄에 토큰이 둘이어도)
# 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴(/s/AbC%44ef…)은 가리지 못한다 — 그 토큰을 가진 본인만 만들 수 있는 꼴이다
# - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다).
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
map $request $log_request_line {
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
map $request $log_request_unqueried {
"" "-";
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
default $request;
}
map $http_referer $log_referer {
map $log_request_unqueried $log_request_pathmasked {
"~*^(?<log_path_head>[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?<log_path_tail>.*)$" $log_path_head***$log_path_tail;
default $log_request_unqueried;
}
map $log_request_pathmasked $log_request_tokenmasked {
"~^(?<log_token1_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token1_tail>.*)$" $log_token1_head***$log_token1_tail;
default $log_request_pathmasked;
}
map $log_request_tokenmasked $log_request_line {
"~^(?<log_token2_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token2_tail>.*)$" $log_token2_head***$log_token2_tail;
default $log_request_tokenmasked;
}
map $http_referer $log_referer_unqueried {
"" "-";
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
default $http_referer;
}
map $log_referer_unqueried $log_referer_pathmasked {
"~*^(?<log_referer_path_head>(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?<log_referer_path_tail>.*)$" $log_referer_path_head***$log_referer_path_tail;
default $log_referer_unqueried;
}
map $log_referer_pathmasked $log_referer {
"~^(?<log_referer_token_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_referer_token_tail>.*)$" $log_referer_token_head***$log_referer_token_tail;
default $log_referer_pathmasked;
}
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
@@ -46,15 +96,118 @@ map "$request_method:$uri:$status" $log_access {
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';
# Content-Security-Policy — XSS 방어의 핵심 헤더. 두 server(포털 · 공개 내려받기)와 공개 화면(/s/)이 이 값 하나를 쓴다 (map 은 늘 같은 값)
#
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데,
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
# style 속성·data: 이미지 표시, CSP 위반 0건)
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다.
# 공개 내려받기 화면은 자기 호스트의 /api/public-links/ 만 부른다 — 'self' 그대로 맞다 (계약 4절 "공개 호스트")
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
map $host $page_csp {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
}
# ----------------------------------------
# 대용량 첨부 받기의 동시 연결 수 (11단계) — 공개 받기(/api/public-links/<토큰>/files/…) · 앱 안 받기(/api/messages/<id>/large-files/…)
#
# 받기는 버퍼링을 끄고 흘려보낸다 — 받는 쪽이 느리면 연결 하나가 nginx 연결 둘(클라이언트 · 백엔드)과 백엔드의 파일을 그동안 잡는다.
# 토큰을 가진 사람이 느린 받기를 수백 개 열어 이 nginx(포털과 함께)를 멈추지 못하게 센다 — 넘으면 429 → @api_too_many_requests 의 REQ_001 JSON:
# 공개 받기 클라이언트 주소마다 10 · 공개 받기 전체 400
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200
# 전체 한도를 둘로 나눈 까닭 — 하나를 함께 쓰면 바깥 주소 몇십 개(IPv6 면 /64 하나)로 공개 받기가 그릇을 채워 직원의 앱 안 받기까지 모두 429 가 된다.
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 두 location 모두 대소문자를 가리지 않는다(위 머리말).
#
# 클라이언트 주소 — 이 nginx 앞에는 늘 외부 리버스 프록시가 있어 $remote_addr 은 프록시다. 프록시는 X-Forwarded-For 에 자기가 본
# 클라이언트 주소를 이어 붙인다(docker 스킬 "외부 리버스 프록시 요구사항") — 그 마지막 칸을 쓴다. 머리글이 없으면(프록시 없이 바로) 접속 주소.
# 프록시를 건너뛰고 바로 붙어 머리글을 꾸미면 주소마다 한도는 피하지만 경로마다의 전체 한도(400 · 200)는 그대로 걸린다(8080 은 ACG · proxy 네트워크가 프록시에만 연다).
# IPv6 는 주소 그대로 센다(백엔드처럼 /64 로 묶지 않는다 — 표기가 여러 꼴이라 map 으로 줄이지 않았다) — 그것도 전체 한도가 막는다.
# 거절은 접근 로그(429 · 토큰은 가려짐)로 본다 — 오류 로그에는 남기지 않는다(info — 이미지의 error_log 는 notice 부터. 남기면 토큰째 요청 줄이 쏟아진다).
# ⚠️ 연결 수의 그릇은 이미지 nginx.conf 의 worker_connections 다 — Dockerfile 이 워커마다 4096 으로 올린다(compose 의 nofile 8192).
# 받기 600 개(연결 1,200) + 실시간 알림(브라우저마다 연결 둘) + 포털 요청이 워커 하나에 몰려도 들어간다
# ----------------------------------------
map $http_x_forwarded_for $download_client {
"~(?:^|,) *(?<download_client_ip>[^ ,]+) *$" $download_client_ip;
default $remote_addr;
}
map $host $download_total_key {
default all;
}
limit_conn_zone $download_client zone=download_public_ip:10m;
limit_conn_zone $download_client zone=download_app_ip:10m;
limit_conn_zone $download_total_key zone=download_public_total:1m;
limit_conn_zone $download_total_key zone=download_app_total:1m;
limit_conn_status 429;
limit_conn_log_level info;
# ----------------------------------------
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
# ----------------------------------------
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
# (map 은 늘 같은 값이다 — server 마다 set 을 쓰지 않으려고)
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
map $host $backend_upstream {
default ${NGINX_BACKEND_UPSTREAM};
}
# 아래 proxy_* 는 두 server 의 /api 가 모두 물려받는다.
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
proxy_http_version 1.1;
proxy_set_header Connection "";
# 원래 Host 를 그대로 넘긴다 — 두 server(포털 · 공개 내려받기) 모두 이 값을 물려받는다(공개 내려받기 server 에서도 바꾸지 않는다).
# 백엔드가 이 값의 호스트 이름으로 공개 호스트를 가른다(modules/file-sharing/public-host.ts — 공개 호스트에서는 /api/public-links/ 만).
# ⚠️ 백엔드는 nginx 가 정규화한 $host 에 기댄다: 소문자 · 포트 없음 · 끝 점 없음, 요청 줄이 절대 주소(absolute-form)면 그 호스트가 이긴다 —
# 그리고 백엔드로 넘기는 요청 줄은 늘 경로만 있는 origin-form 이다. 이 nginx 를 거치지 않고 백엔드에 닿는 길을 만들거나 다른 프록시를 두면 같은 정규화를 해야 한다
proxy_set_header Host $host;
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
proxy_set_header X-Request-Id $request_id;
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie·Range·Content-Digest 는 손대지 않고 그대로 넘긴다.
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
# ----------------------------------------
# 압축 (Gzip) — 두 server 공통. 실시간 알림 · 대용량 받기는 각 location 에서 끈다
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ==========================================
# 포털 — 웹메일 화면과 /api (Host 가 공개 내려받기 호스트가 아닌 모든 요청)
# ==========================================
server {
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
listen 8080;
server_name localhost;
listen 8080 default_server;
# 이름으로 고르지 않는다(default_server) — 공개 내려받기 server 의 이름과 겹치지 않게 비워 둔다
server_name _;
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 · 토큰 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
access_log /var/log/nginx/access.log main_rid if=$log_access;
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
client_max_body_size 1m;
# ----------------------------------------
@@ -74,42 +227,8 @@ server {
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (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 문서는 이 정책을 상속하는데,
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
# style 속성·data: 이미지 표시, CSP 위반 0건)
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# 값과 설명은 위 http 단계의 map $page_csp
add_header Content-Security-Policy $page_csp always;
# ----------------------------------------
# SPA 라우팅
@@ -144,29 +263,13 @@ server {
add_header Cache-Control "no-cache";
}
# ----------------------------------------
# /api → 백엔드 (같은 주소 배포)
# ----------------------------------------
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
set $backend_upstream ${NGINX_BACKEND_UPSTREAM};
# 아래 proxy_* 는 /api 의 모든 location 이 물려받는다.
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
proxy_set_header X-Request-Id $request_id;
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie 는 손대지 않고 그대로 넘긴다.
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
# 대용량 첨부의 공개 화면 /s/<토큰>(11단계). 공개 호스트를 따로 두면 여기서는 404 로 닫고(nginx-share-closed.conf),
# 따로 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-open.conf). 고르는 것은 nginx-env.envsh 의 NGINX_PORTAL_SHARE.
# 공개 API /api/public-links/ 는 아래 /api/ 블록 안에서 같은 값으로 고른다(share-api-*.conf)
include /etc/nginx/snippets/share-${NGINX_PORTAL_SHARE}.conf;
location ^~ /api/ {
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다 (안쪽 location 도 이 off 를 물려받는다)
add_header_inherit off;
proxy_pass $backend_upstream;
@@ -175,6 +278,9 @@ server {
error_page 413 @api_payload_too_large;
error_page 500 502 503 504 @api_unavailable;
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
location ~* ^/api/health(?:/|$) {
@@ -185,6 +291,49 @@ server {
return 404 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
}
# 공개 API /api/public-links/ (11단계) — 공개 호스트를 따로 두면 404 RES_001 로 닫고(nginx-share-api-closed.conf),
# 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-api-open.conf — 파일 받기는 공개 받기의 동시 연결 수 · 버퍼링 끔). 대소문자를 가리지 않는다
include /etc/nginx/snippets/share-api-${NGINX_PORTAL_SHARE}.conf;
# 대용량 첨부 받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 공개 받기와 같게 흘려보낸다
# (nginx-share-api-open.conf 의 파일 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
# - 동시 연결은 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 (위 http 단계). 넘으면 429 REQ_001
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 이 응답을 버퍼에 쌓지 않는다(받는 사람마다 1GB 를 디스크에 쌓지 않게).
# Range 는 손대지 않고 넘긴다(206 그대로)
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
# - 대소문자를 가리지 않는다(머리말). id 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다
# (지금은 백엔드의 id 검사가 그 꼴을 404 로 막지만 거기에 기대지 않는다)
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
location ~* ^/api/messages/.+/large-files/ {
limit_conn download_app_ip 30;
limit_conn download_app_total 200;
error_page 413 @api_payload_too_large;
error_page 429 @api_too_many_requests;
error_page 500 502 503 504 @api_unavailable;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
gzip off;
proxy_pass_header X-Accel-Buffering;
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 대용량 첨부의 조각 올리기 PUT /api/file-uploads/{id}/chunks/{n} (11단계 — 계약 4절 "올리기") — 조각 16MiB(계약 상수 — 마지막 조각만 짧다)
# + 여유 = 17m 고정. 계약 상수라 env 로 바꾸지 않는다(바꾸려면 계약 · 백엔드 · 화면을 함께). 그 밖의 /api/file-uploads(만들기 · 끝내기 · 상태 · 지우기)는
# JSON 이라 기본 1m 그대로다. 요청 본문은 nginx 가 다 받아 둔 뒤(proxy_request_buffering 기본 켬 — 느린 연결은 nginx 가 받는다, /tmp/client_temp)
# 백엔드에 넘긴다 — 백엔드가 디스크에 쓰고 내려쓰기(datasync)까지 기다린다. 백엔드 라우팅처럼 대소문자를 가리지 않는다.
# 오류 로그는 error 부터 — 16MiB 조각은 늘 메모리 버퍼(client_body_buffer_size)를 넘어 임시 파일로 받는데, nginx 는 그때마다
# "a client request body is buffered to a temporary file" 을 warn 으로 남긴다(조각마다 한 줄 — 2GB 파일이면 128줄). 버퍼를 키우지 않는다(동시 조각 수 × 16MiB 가 메모리에 앉는다)
location ~* ^/api/file-uploads/[^/]+/chunks/[^/]+$ {
client_max_body_size 17m;
proxy_read_timeout 90s;
error_log /var/log/nginx/error.log error;
proxy_pass $backend_upstream;
}
# 메일 첨부 업로드 — 파일당 MAIL_ATTACHMENT_MAX_SIZE_MB + 멀티파트 여유 1MB (nginx-env.envsh).
# 본문을 다 받은 뒤 백엔드가 메일 엔진에 올리는 시간까지 기다린다 (프론트의 업로드 한도는 5분)
location ^~ /api/mail-attachments {
@@ -199,8 +348,9 @@ server {
proxy_pass $backend_upstream;
}
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m)
location ^~ /api/messages {
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m).
# ^~ 를 붙이지 않는다 — 대용량 첨부 받기(/api/messages/{id}/large-files/…)는 위 정규식 location 이 가져가야 한다(위 ⚠️)
location /api/messages {
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
@@ -247,8 +397,8 @@ server {
# 버퍼링을 끈다 — 기본 설정의 nginx 가 머리글을 첫 이벤트까지 붙잡지 않고 바로 보낸다 (docker 스킬 "외부 리버스 프록시 요구사항").
# 브라우저는 이 머리글을 쓰지 않는다.
# 머리글(proxy_set_header — 여기 쓰지 않는다) · 오류 응답(@api_unavailable 등) · 접근 로그는 위 /api 와 server 단계 것을 그대로 물려받는다.
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 이미지 기본 nginx.conf 를 따른다
# (워커마다 worker_connections 1024, 워커 수 auto).
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 워커마다 worker_connections 4096
# (Dockerfile 이 이미지 기본 1024 에서 올린다 — 대용량 받기와 함께 버티게, 위 http 단계 "동시 연결 수"), 워커 수 auto.
location ^~ /api/mail-events {
proxy_buffering off;
proxy_cache off;
@@ -259,27 +409,9 @@ server {
}
}
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
# 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;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Request-Id $request_id always;
return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
}
location @api_unavailable {
add_header_inherit off;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Request-Id $request_id always;
return 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
}
# /api 의 nginx 오류 응답 — @api_not_found(404 RES_001) · @api_payload_too_large(413 FILE_002) · @api_too_many_requests(429 REQ_001) ·
# @api_unavailable(5xx SYS_001)
include /etc/nginx/snippets/api-errors.conf;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
@@ -293,3 +425,66 @@ server {
log_not_found off;
}
}
# ==========================================
# 공개 내려받기 — 대용량 첨부의 공개 호스트 (11단계 — 계약 4절 "공개 호스트", 운영 files.arasystem.it.kr)
#
# 로그인 없이 바깥 사람이 여는 곳이다. 여는 것은 셋뿐이다 — 화면 /s/<토큰>(SPA 의 공개 라우트 — 같은 묶음의 index.html) · 화면 자산(/assets/ · /favicon.ico) ·
# 공개 API /api/public-links/. 그 밖은 화면 · API 모두 404 — 털린 계정이 퍼뜨린 링크가 포털의 화면 · API 로 이어지지 않게 (백엔드도 같은 판정으로 막는다).
# 공개 호스트를 따로 두지 않으면 이 server 의 이름은 어느 요청에도 맞지 않는 share.invalid 이고(nginx-env.envsh), 포털이 같은 것을 연다.
# 포털 쿠키는 호스트 한정(Domain 없음)이라 이 호스트로 오지 않는다.
# ==========================================
server {
listen 8080;
server_name ${NGINX_SHARE_SERVER_NAME};
# 접근 로그 — 포털과 같은 형식(쿼리 없음 · 경로의 토큰은 ***)
access_log /var/log/nginx/access.log main_rid if=$log_access;
# 받기만 한다 — 본문을 받는 경로가 없다
client_max_body_size 1m;
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
add_header_inherit merge;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Content-Security-Policy $page_csp always;
root /usr/share/nginx/html;
# 화면 /s/<토큰> (포털이 같은 호스트일 때와 같은 파일)
include /etc/nginx/snippets/share-open.conf;
# 화면 자산 — 포털과 같은 캐시 (해시가 든 /assets/ 는 30일 immutable, 해시가 없는 favicon 은 매번 재검증)
location ^~ /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location = /favicon.ico {
add_header Cache-Control "no-cache";
}
# API — 공개 API /api/public-links/ 만 백엔드로 넘기고(포털이 같은 호스트일 때와 같은 파일 — 대소문자를 가리지 않는다),
# 그 밖은 없는 경로와 같은 404 RES_001 JSON (@api_not_found). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
# add_header 는 여기 두지 않는다 — 두면 안쪽의 백엔드로 넘기는 location 이 물려받는다(JSON 머리글은 @api_* 가 붙인다).
# return 은 안쪽 location 으로 물려지지 않는다 — 안쪽에 맞은 요청은 백엔드로 간다
location ^~ /api/ {
add_header_inherit off;
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
include /etc/nginx/snippets/share-api-open.conf;
return 404;
}
# 그 밖의 화면 · 파일 (/ · /index.html · /mail/… 등) — 404
location / {
return 404;
}
# API 의 nginx 오류 응답 (포털과 같은 파일)
include /etc/nginx/snippets/api-errors.conf;
}