From 93147b88b76c05a715fff60d9761623eee5348dc Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 00:37:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(docker):=20=EB=8C=80=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=E2=80=94=20ClamAV=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=B5=EA=B0=9C=20=EB=82=B4=EB=A0=A4?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EB=8F=99=EC=8B=9C=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 _files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다. - CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부. - mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결. Co-Authored-By: Claude Opus 5.5 --- .env.development.example | 32 +++ .env.production.example | 36 ++- .github/workflows/ci.yml | 123 ++++++++- .gitignore | 2 + backend/.dockerignore | 2 + backend/Dockerfile | 7 +- docker-compose.dev.yml | 22 ++ docker-compose.yml | 153 ++++++++++- frontend/Dockerfile | 14 +- frontend/nginx-api-errors.conf | 48 ++++ frontend/nginx-env.envsh | 72 ++++- frontend/nginx-share-api-closed.conf | 14 + frontend/nginx-share-api-open.conf | 47 ++++ frontend/nginx-share-closed.conf | 12 + frontend/nginx-share-open.conf | 27 ++ frontend/nginx.conf | 375 ++++++++++++++++++++------- mail/README.md | 200 +++++++++++++- mail/clamav/clamd.conf | 66 +++++ mail/clamav/freshclam.conf | 27 ++ 19 files changed, 1176 insertions(+), 103 deletions(-) create mode 100644 frontend/nginx-api-errors.conf create mode 100644 frontend/nginx-share-api-closed.conf create mode 100644 frontend/nginx-share-api-open.conf create mode 100644 frontend/nginx-share-closed.conf create mode 100644 frontend/nginx-share-open.conf create mode 100644 mail/clamav/clamd.conf create mode 100644 mail/clamav/freshclam.conf diff --git a/.env.development.example b/.env.development.example index 42db467..ffc1a83 100644 --- a/.env.development.example +++ b/.env.development.example @@ -91,6 +91,8 @@ DB_SYNCHRONIZE=false # mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail # redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis # frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend +# clamav 대용량 첨부의 악성 검사기(ClamAV — 메모리 1GB 남짓 · 처음에 서명 110MB 를 받는다) +# → COMPOSE_PROFILES=mail,clamav 와 함께 아래 "대용량 첨부" 의 CLAMAV_HOST=clamav (운영은 profile 없이 늘 뜬다) # # 일회성으로 켜려면: docker compose --profile redis ... up -d # @@ -108,6 +110,36 @@ COMPOSE_PROFILES=mail UPLOAD_DATA_DIR=./data/uploads UPLOAD_MAX_SIZE_MB=5 +# ========================================== +# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)") +# +# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 메일 엔진이 아니라 백엔드의 볼륨(_files — 개발도 named volume)에 올리고, +# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다 — 비워 두어도 그 값이다. +# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처(스킴 · 호스트[· 포트] — 경로 · 끝의 / 없이). 개발은 비운다 = CORS_ORIGIN 의 첫 주소(포털과 같은 호스트). +# 운영은 포털과 다른 호스트다(.env.production.example) +# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@) +# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000) +# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상) +# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20) +# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함) +# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90) +# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다 +# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 개발은 Docker Desktop 가상 디스크의 여유를 본다 +# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd). 개발은 비워 둔다 = 검사하지 않고 다 올리자마자 "검사하지 못함". +# 켜려면 COMPOSE_PROFILES=mail,clamav 와 함께 CLAMAV_HOST=clamav (compose 의 clamav 서비스, 포트 3310) +# ========================================== +SHARE_PUBLIC_URL= +SHARE_REPORT_ADDRESS= +SHARE_DOWNLOAD_LIMIT=500 +MAIL_LARGE_FILE_MAX_SIZE_MB=2047 +MAIL_LARGE_FILE_MAX_COUNT=10 +MAIL_LARGE_FILE_USER_QUOTA_GB=10 +MAIL_LARGE_FILE_RETENTION_DAYS=30 +FILE_STORAGE_MAX_TOTAL_GB= +FILE_STORAGE_MIN_FREE_GB=10 +CLAMAV_HOST= +CLAMAV_PORT=3310 + # ========================================== # 메일 엔진 (Stalwart) — profile `mail` # diff --git a/.env.production.example b/.env.production.example index 698c961..a8d2249 100644 --- a/.env.production.example +++ b/.env.production.example @@ -77,6 +77,8 @@ REDIS_DATA_DIR=./data/redis # true·0·false 는 운영에서 기동이 거부된다. # ========================================== BACKEND_API_URL=https://portal.arasystem.it.kr/api +# ⚠️ 대용량 첨부의 공개 내려받기 호스트(아래 SHARE_PUBLIC_URL — files.)는 여기에 넣지 않는다 — 넣으면 공개 호스트 분리가 꺼져 +# 포털에서도 공개 링크가 열린다(백엔드가 운영에서는 기동을 막는다). 공개 화면은 자기 호스트의 /api/public-links/ 만 부른다 CORS_ORIGIN=https://portal.arasystem.it.kr TRUST_PROXY=2 @@ -100,7 +102,7 @@ DB_SYNCHRONIZE=false # 선택적 서비스 (Compose profiles) # # 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다. -# (redis 는 기동하지 않는다) +# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부") # 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다. # COMPOSE_PROFILES=mail,redis # 일회성으로 켜려면: docker compose --profile redis ... up -d @@ -133,6 +135,38 @@ FRONTEND_PRIVATE_BIND= # ========================================== UPLOAD_MAX_SIZE_MB=5 +# ========================================== +# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)" · "대용량 첨부 — 운영") +# +# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 백엔드의 볼륨(_files — 30일짜리 전송물이라 백업하지 않는다)에 올리고, +# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다(SHARE_PUBLIC_URL · CLAMAV_HOST 는 운영 필수). +# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처 — **운영 필수**, https, 경로 · 끝의 / 없이. 포털과 다른 호스트로 둔다: +# 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지 차단 목록에 오르는 위험을 떼어 놓고, 포털 쿠키도 그 호스트로 가지 않는다. +# DNS(가비아 files A → 외부 리버스 프록시 서버) · 외부 프록시의 Proxy Host(같은 사설 IP:8080, 접근 로그 끔)가 있어야 한다. +# ⚠️ 이 호스트를 위 CORS_ORIGIN 에 넣지 않는다(넣으면 분리가 꺼진다 — 백엔드가 기동을 막는다). +# ⚠️ 한 번 정하면 바꾸지 않는다 — 이미 보낸 메일의 링크가 이 주소다(옛 호스트가 남아 있어야 열린다) +# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@) +# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000) +# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상) +# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20) +# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함) +# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90) +# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다. 디스크를 메일 · DB 와 함께 쓰면 정해 둔다 +# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 도커 data-root 의 디스크(볼륨 · 악성 검사의 임시 파일이 함께 쓴다) +# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd) — **운영 필수**. compose 의 clamav 서비스(호스트 이름만 — 스킴 · 포트 없이) +# ========================================== +SHARE_PUBLIC_URL=https://files.arasystem.it.kr +SHARE_REPORT_ADDRESS= +SHARE_DOWNLOAD_LIMIT=500 +MAIL_LARGE_FILE_MAX_SIZE_MB=2047 +MAIL_LARGE_FILE_MAX_COUNT=10 +MAIL_LARGE_FILE_USER_QUOTA_GB=10 +MAIL_LARGE_FILE_RETENTION_DAYS=30 +FILE_STORAGE_MAX_TOTAL_GB= +FILE_STORAGE_MIN_FREE_GB=10 +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 + # ========================================== # 메일 엔진 (Stalwart) — profile `mail` # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5348e51..fea8973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,14 +180,132 @@ jobs: run: | tag="ci-nginx-config:${{ github.run_id }}" docker build --target nginx-config -t "$tag" frontend + # 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf) docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t - # 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소) + # 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다 + docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t + rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null) + for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \ + 'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do + printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; } + done + # 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소) if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1 fi if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1 fi + for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do + if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then + echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + + # 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 · + # X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말). + # 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로, + # ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다 + - name: 대용량 받기 경로 시험 + run: | + base="ci-nginx-config:${{ github.run_id }}" + tag="ci-nginx-paths:${{ github.run_id }}" + name="ci-nginx-paths-${{ github.run_id }}" + ctx=$(mktemp -d) + cat > "$ctx/paths.sh" <<'EOF' + #!/bin/sh + P=http://127.0.0.1:8080 + T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1 + F=11111111-2222-4333-8444-555555555555 + fails=0 + # host path → "상태 xab|-" + probe() { + timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk ' + /^ HTTP\// { s = $2 } + / error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) } + tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" } + END { print (s ? s : "none"), (x ? x : "-") }' + } + # host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON + raw() { + { printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; } \ + | timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk ' + NR == 1 { s = $2 } + /^[Cc]ontent-[Tt]ype:/ { t = $2 } + /^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) } + END { print s, (t ? t : "-"), (c ? c : "-") }' + } + check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; } + expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; } + expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; } + hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; } + release() { killall wget >/dev/null 2>&1; sleep 2; } + PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F" + INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F" + + echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도" + for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done + for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done + expect "200 -" files.ci.test "/api/Public-Links/$T" + expect "200 -" portal.ci.test "/api/Messages/M1" + expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1" + expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1" + echo "== nginx 가 끝내는 /api 응답은 JSON" + expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me + expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000" + expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F" + echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더" + hold files.ci.test "/api/public-links/$T/files/$F" 10 + for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done + expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F" + expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F" + release + hold portal.ci.test "/api/messages/M1/large-files/$F" 30 + for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done + expect "200 xab" files.ci.test "/api/public-links/$T/files/$F" + release + echo "fails=$fails" + [ "$fails" -eq 0 ] + EOF + { printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile" + RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \ + && pad=$(head -c 3000 /dev/zero | tr '\0' a) \ + && printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf + COPY --chmod=755 paths.sh /usr/local/bin/paths.sh + EOF + docker build -t "$tag" "$ctx" + docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \ + -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" + for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done + docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; } + + # 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf. + # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만 + # 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 — + # AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)") + - name: ClamAV 설정 검사 + run: | + touch backend/.env.production + # clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서 + # compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭) + image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }') + [ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; } + tag="ci-clamav-config:${{ github.run_id }}" + printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav + conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1) + printf '%s\n' "$conf" + if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then + echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1 + fi + for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do + printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; } + done + # -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은 + # -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게) + clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on') + for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do + printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; } + done # 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf. # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다) @@ -202,8 +320,11 @@ jobs: - name: 정리 if: always() run: | + docker rm -f "ci-nginx-paths-${{ github.run_id }}" || true + docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true + docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true # 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다) docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true diff --git a/.gitignore b/.gitignore index 1ae546a..602e317 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ ci/runner-data/ # 업로드 파일 (로컬 저장) — 코드가 아니다 backend/uploads/ +# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다 +backend/files/ # ========================================== # 의존성/빌드 산출물 diff --git a/backend/.dockerignore b/backend/.dockerignore index 63ac85f..247fa1c 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -12,6 +12,8 @@ node_modules dist coverage uploads +# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게) +files logs *.log .env diff --git a/backend/Dockerfile b/backend/Dockerfile index a90b692..4e18384 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,6 +25,9 @@ RUN npm ci COPY --chown=node:node . . RUN npm run build +# 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — dev 는 이 단계에 named volume _files 를 붙인다. +# 새 볼륨은 이 디렉터리의 소유자(node)를 물려받는다 (없으면 root 소유로 생겨 node 가 쓰지 못한다). 빌드 컨텍스트의 files/ 는 .dockerignore 가 뺀다 +RUN mkdir -p /app/files FROM node:${NODE_VERSION}-alpine3.24 AS production ARG NPM_VERSION @@ -41,10 +44,10 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist -# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다. +# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다. # 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다. # (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다) -RUN mkdir -p /app/uploads && chown node:node /app/uploads +RUN mkdir -p /app/uploads /app/files && chown node:node /app/uploads /app/files EXPOSE 3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 69658ca..ce04651 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -83,6 +83,10 @@ services: # 소스 바인드 마운트(/app) 위에 다시 얹는다. Windows·macOS 의 Docker Desktop 은 소유자 문제가 없고, # 리눅스 호스트는 도커가 없는 디렉터리를 root 소유로 만들므로 처음 한 번 mkdir -p data/uploads 를 해 둔다 - ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads + # 대용량 첨부 파일(11단계)은 개발도 base 그대로 named volume _files(/app/files)다 — 호스트 디렉터리로 바꾸지 않는다: + # 2GB 까지의 파일을 Windows 바인드 마운트 너머로 쓰고 읽으면 느리고, 호스트 백신(Windows Defender)이 시험 파일(EICAR 등)을 + # 지워 악성 검사 시험이 어긋난다. 새 볼륨은 builder 단계의 /app/files(node 소유)를 물려받는다 (backend/Dockerfile). + # 디스크 여유 검사(FILE_STORAGE_MIN_FREE_GB)는 Docker Desktop 가상 디스크의 여유를 본다 # dev 는 watch 컴파일(swc)과 타입 검사(tsc, nest-cli.json 의 typeCheck)가 앱과 함께 돌아 # base 의 1g 로는 OOM 으로 두 번 죽었다 → dev 에서만 2g. 운영 한도(1g)는 그대로다 deploy: @@ -117,6 +121,24 @@ services: # 개발도 같은 리졸버를 띄운다 — 엔진은 도커 DNS 를 거쳐(System) 바깥 이름을 이 리졸버에 묻는다(Mailpit 같은 도커 내부 이름은 도커 DNS 가 푼다) restart: "no" + # ---------------------------------------- + # 악성 검사 (ClamAV) — 개발은 기본으로 띄우지 않는다 (profile `clamav`, 운영은 늘 뜬다) + # + # 서명을 읽은 검사기가 메모리를 1GB 안팎 쓰고, 처음 기동은 서명(110MB 안팎)을 받는다. 끈 동안 백엔드(CLAMAV_HOST 비움)는 + # 다 올린 대용량 파일을 바로 "검사하지 못함"으로 둔다 (계약 4절 "악성 검사"). 켜려면 루트 .env.development 에 둘 다: + # COMPOSE_PROFILES=mail,clamav + # CLAMAV_HOST=clamav + # 그다음 docker compose ... up -d (backend 가 새 값으로 다시 만들어진다). 서명은 볼륨 _clamav-db 에 남아 다음에는 바로 뜬다 + # ---------------------------------------- + clamav: + profiles: ["clamav"] + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 unless-stopped 를 덮어씀) + restart: "no" + + clamav-updater: + profiles: ["clamav"] + restart: "no" + # ---------------------------------------- # mailpit — 개발 전용 메일 수집기 (profile `mail`, 운영 compose 에는 없다) # diff --git a/docker-compose.yml b/docker-compose.yml index 4964697..ee91c1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,10 @@ services: # app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다 # (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다) # mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조) + # clamav backend ↔ clamav(악성 검사기) 전용 — 바깥 길 없음. 서명 갱신(clamav-updater)은 clamav-egress 로만 (7번 참조) + # + # 호스트 이름 둘 — 포털(CORS_ORIGIN)과 대용량 첨부의 공개 내려받기(SHARE_PUBLIC_URL, 11단계)를 server 하나씩으로 가른다 + # (frontend/nginx.conf · nginx-env.envsh). 외부 리버스 프록시는 두 호스트를 모두 이 컨테이너의 8080 으로 넘기고 Host 를 그대로 넘긴다 # ---------------------------------------- frontend: container_name: ${PROJECT_NAME}-frontend @@ -32,7 +36,16 @@ services: # 서비스 이름(backend)이 아니라 고유 컨테이너 이름 — proxy 네트워크를 다른 프로젝트와 함께 쓰면 # 그쪽의 backend 라는 이름으로 풀릴 수 있다 (nginx 는 붙은 모든 네트워크에서 이름을 찾는다) - NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} + # 대용량 첨부의 공개 내려받기 호스트(11단계) — 백엔드와 같은 두 값으로 가른다: SHARE_PUBLIC_URL 의 호스트 이름이 포털(CORS_ORIGIN)과 다르면 + # 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh) + - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} + - CORS_ORIGIN=${CORS_ORIGIN:-} restart: unless-stopped + # 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다 + ulimits: + nofile: + soft: 8192 + hard: 8192 networks: - edge - proxy @@ -115,19 +128,41 @@ services: - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} # 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다 - MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25} + # 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절 "환경 변수"). 비운 값은 백엔드 기본값이다 (검사: shared/config/env.validation.ts) + # 공개 내려받기 화면의 출처 — 운영 필수(https, 포털과 다른 호스트 권장 · 예 https://files.arasystem.it.kr). 비우면(개발) CORS_ORIGIN 의 첫 주소 + - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} + - SHARE_REPORT_ADDRESS=${SHARE_REPORT_ADDRESS:-} + - SHARE_DOWNLOAD_LIMIT=${SHARE_DOWNLOAD_LIMIT:-} + - MAIL_LARGE_FILE_MAX_SIZE_MB=${MAIL_LARGE_FILE_MAX_SIZE_MB:-} + - MAIL_LARGE_FILE_MAX_COUNT=${MAIL_LARGE_FILE_MAX_COUNT:-} + - MAIL_LARGE_FILE_USER_QUOTA_GB=${MAIL_LARGE_FILE_USER_QUOTA_GB:-} + - MAIL_LARGE_FILE_RETENTION_DAYS=${MAIL_LARGE_FILE_RETENTION_DAYS:-} + - FILE_STORAGE_MAX_TOTAL_GB=${FILE_STORAGE_MAX_TOTAL_GB:-} + - FILE_STORAGE_MIN_FREE_GB=${FILE_STORAGE_MIN_FREE_GB:-} + # 파일을 두는 곳 — 아래 named volume _files (루트 env 로 바꾸지 않는다) + - FILE_STORAGE_DIR=/app/files + # 악성 검사기(clamd) — 운영 필수: 아래 clamav 서비스(호스트 이름 clamav, 포트 3310). 비우면(개발 기본) 검사하지 않고 다 올리자마자 "검사하지 못함" + - CLAMAV_HOST=${CLAMAV_HOST:-} + - CLAMAV_PORT=${CLAMAV_PORT:-} # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume _uploads. # 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 # backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile). # (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다) # (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크. # read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다) + # 대용량 첨부 파일(11단계)은 named volume _files — 같은 이유로 이미지의 /app/files(node 소유)를 물려받는다. + # 한 파일 2GB 까지 · 직원마다 10GB 까지 쌓인다 — 디스크 여유는 백엔드가 만들 때 본다(FILE_STORAGE_MIN_FREE_GB). 30일짜리 전송물이라 백업하지 않는다 + # (개발도 같은 named volume 이다 — 업로드와 달리 호스트 디렉터리로 바꾸지 않는다, docker-compose.dev.yml) volumes: - uploads:/app/uploads + - files:/app/files restart: unless-stopped - # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다 + # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다. + # clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번) networks: - app-network - edge + - clamav depends_on: db: condition: service_healthy @@ -325,6 +360,99 @@ services: cpus: '0.25' memory: 128m + # ---------------------------------------- + # 7. 악성 검사 (ClamAV) — 대용량 첨부(11단계)의 검사기 · 운영은 늘 뜬다 (개발은 profile `clamav` — docker-compose.dev.yml) + # + # 백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (docs/mail/api-contract.md 4절 "악성 검사", + # backend modules/file-storage/clamav-client.ts). 설정 mail/clamav/ · 자세한 것 mail/README.md "악성 검사 (ClamAV)". + # + # 둘로 나눈다 — 바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다: + # clamav clamd(검사기). 백엔드와만 닿는 clamav 네트워크(internal) 하나에 붙는다 — 바깥 길이 없다(파일 해석기에 구멍이 나도 + # 바깥 · 다른 컨테이너에 닿지 못하게). 서명 볼륨은 읽기 전용으로 붙이고 바뀐 서명을 스스로 다시 읽는다(clamd.conf 의 SelfCheck) + # clamav-updater freshclam(서명 갱신). 서명을 받아 볼륨에 쓴다 — 자기 바깥 길 clamav-egress 에만 붙고 백엔드 · 검사기와 잇지 않는다 + # 이미지는 공식 clamav/clamav 의 LTS(1.4) 판을 digest 까지 고정한다(docker 스킬 "이미지 버전 고정") — _base 는 서명이 들지 않은 이미지다(서명은 볼륨에). + # 두 컨테이너 모두 이미지의 clamav 사용자(100:101) · 권한(capability) 없음 · no-new-privileges. 호스트 포트는 어떤 환경에서도 열지 않는다 + # ---------------------------------------- + clamav: + container_name: ${PROJECT_NAME}-clamav + # 1.4 는 ClamAV 의 LTS(2024-08 부터 3년). 1.4.6 은 보안 릴리스(2026-08-07). digest 는 2026-10-30 의 manifest list (이미지는 linux/amd64 만) + image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f + user: "100:101" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + # 이미지의 /init 은 root 로 소유자를 바꾸고 freshclam 까지 함께 띄운다 — 검사기만 바로 띄운다. PID 1 은 docker 의 tini(sh 는 exec 로 clamd 가 된다). + # 띄우기 전에 clamd 의 임시 파일(clamd.conf 의 TemporaryDirectory /tmp — clamav-.tmp)을 지운다: 검사 중에 죽으면(OOM 등) 받던 스트림 · + # 푼 내용이 남고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다(2026-10-30 — 흘려보내는 중에 죽일 때마다 수백 MB 씩 남았다). + # 지우지 못해도(소유자가 다르면) clamd 는 띄운다 — 그 줄이 docker logs 에 남는다 + init: true + entrypoint: ["/bin/sh", "-c", "rm -rf /tmp/clamav-*; exec clamd --foreground --config-file=/etc/clamav/clamd.conf"] + expose: + - "3310" + volumes: + - clamav-db:/var/lib/clamav:ro + - ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro + restart: unless-stopped + networks: + - clamav + # 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다). + # required: false — 서명이 오래됐다고(아래 updater 의 healthcheck) 검사기를 띄우지 않으면 검사가 통째로 서므로 경고만 내고 띄운다 + depends_on: + clamav-updater: + condition: service_healthy + required: false + healthcheck: + # 이미지에 든 clamdcheck.sh — 3310 에 PING 을 보내 PONG 을 받는지. 서명을 다시 읽는 동안(30초 안팎)은 답하지 않는다 — 세 번까지 기다린다 + test: ["CMD", "clamdcheck.sh"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 5m + # CPU — 검사 하나는 코어 하나만 쓴다(2GB 에 5분 안팎 — 2026-10-30 개발 PC). 백엔드가 한 번에 2개까지 보내므로 2개까지 쓰게 두고(서버가 2 vCPU 보다 적으면 낮춘다), + # 다른 컨테이너와 다툴 때는 절반의 몫(cpu_shares 512 — 기본 1024)만 받는다 — 검사가 웹메일 · 메일 엔진을 늦추지 않게 + cpu_shares: 512 + # 메모리 — 서명을 읽으면 1GB 안팎(2026-10 서명 363만 개). 다시 읽을 때 두 벌을 들지 않는다(clamd.conf 의 ConcurrentDatabaseReload no — 1GB 그대로). + # 검사하는 동안 받은 파일을 /tmp 에 써 그 페이지 캐시가 이 한도에 함께 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다). 예산은 mail/README.md "메모리 예산" + deploy: + resources: + limits: + cpus: '2.0' + memory: 2g + + clamav-updater: + container_name: ${PROJECT_NAME}-clamav-updater + image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f + user: "100:101" + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + init: true + # freshclam 데몬 — 기동 때 한 번 받고 Checks(하루 12번)마다 묻는다. 멈추면 컨테이너가 끝나 재시작 정책을 탄다 + entrypoint: ["freshclam", "--daemon", "--foreground", "--stdout", "--config-file=/etc/clamav/freshclam.conf"] + volumes: + - clamav-db:/var/lib/clamav + - ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro + restart: unless-stopped + networks: + - clamav-egress + healthcheck: + # 서명(main · daily)이 있고 daily 가 이틀 안에 바뀌었으면 healthy — 갱신이 이틀 넘게 멈추면 unhealthy(docker ps 로 보인다. 검사는 옛 서명으로 이어 간다). + # 처음 기동은 서명을 받는 몇 분 동안 실패해도 세지 않는다(start_period) + test: ["CMD-SHELL", "ls /var/lib/clamav/main.c[lv]d >/dev/null 2>&1 && find /var/lib/clamav -maxdepth 1 -name 'daily.c[lv]d' -mmin -2880 | grep -q ."] + interval: 60s + timeout: 10s + retries: 3 + start_period: 10m + # 메모리 — 평소 수십 MB. 받은 서명을 바꿔 넣기 전에 읽어 보는 동안(freshclam.conf 의 TestDatabases) 잠시 커진다 — + # 처음 기동(서명 전체)이 가장 커서 0.7GB 남짓이었다(2026-10-30). 평소 갱신은 daily 만이라 그보다 작다 + deploy: + resources: + limits: + cpus: '0.5' + memory: 1g + networks: app-network: # 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외 @@ -359,17 +487,34 @@ networks: # 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md) name: ${PROJECT_NAME}_mail-dns-egress driver: bridge + clamav: + # backend ↔ clamav(검사기) 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(검사기는 바깥 길이 없다). + # clamd 는 암호화하지 않은 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 도커가 정한다 + name: ${PROJECT_NAME}_clamav + driver: bridge + internal: true + clamav-egress: + # 서명 갱신(clamav-updater)이 database.clamav.net 에 묻는 길 — updater 혼자 붙는다. 서브넷은 도커 기본 주소 풀(172.17~31.x · 192.168.x)에서 — + # 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail-dns-egress 와 같다) + name: ${PROJECT_NAME}_clamav-egress + driver: bridge proxy: external: true # 메일 설정(config.json)·저장소(RocksDB). 컨테이너 사용자(UID 2000)가 써야 하므로 # 바인드 마운트 대신 named volume 을 쓴다. 이름을 고정해 백업 명령이 디렉터리 이름에 좌우되지 않게 한다. -# 업로드도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. -# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 파일이 사라진다. +# 업로드 · 대용량 첨부 파일도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. 악성 검사 서명은 clamav 사용자(100)가 쓴다. +# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 · 대용량 첨부 파일이 사라진다. volumes: mail-config: name: ${PROJECT_NAME}_mail-config mail-data: name: ${PROJECT_NAME}_mail-data uploads: - name: ${PROJECT_NAME}_uploads \ No newline at end of file + name: ${PROJECT_NAME}_uploads + # 대용량 첨부 파일 (11단계) — 기록은 DB 에 있다. 백업하지 않는다(30일짜리 전송물 — mail/README.md "백업") + files: + name: ${PROJECT_NAME}_files + # 악성 검사 서명 (110MB 안팎 — 2026-10) — 지워도 clamav-updater 가 다시 받는다. 백업하지 않는다 + clamav-db: + name: ${PROJECT_NAME}_clamav-db \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ea647fd..3b759b1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -46,8 +46,20 @@ FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS nginx-config ENV NGINX_ENVSUBST_FILTER=^NGINX_ \ NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 COPY nginx.conf /etc/nginx/templates/default.conf.template -# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도). 실행 권한이 있어야 entrypoint 가 source 한다 +# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도, 공개 내려받기 호스트). 실행 권한이 있어야 entrypoint 가 source 한다 COPY --chmod=755 nginx-env.envsh /docker-entrypoint.d/16-nginx-env.envsh +# 두 server(포털 · 공개 내려받기 — 11단계)가 include 하는 location 묶음. 템플릿이 아니다 — envsubst 를 거치지 않고 그대로 읽는다. +# share-open · share-closed 는 server 단계(화면 /s/), share-api-open · share-api-closed 는 /api/ 블록 안(공개 API — 대소문자를 가리지 않는다) +COPY nginx-share-open.conf /etc/nginx/snippets/share-open.conf +COPY nginx-share-closed.conf /etc/nginx/snippets/share-closed.conf +COPY nginx-share-api-open.conf /etc/nginx/snippets/share-api-open.conf +COPY nginx-share-api-closed.conf /etc/nginx/snippets/share-api-closed.conf +COPY nginx-api-errors.conf /etc/nginx/snippets/api-errors.conf +# 워커마다 연결 수 — 이미지 기본 1024 → 4096. 대용량 받기(버퍼링 끔 — 받기 하나가 연결 둘, 공개 400 · 앱 안 200 개까지) · 실시간 알림(브라우저마다 연결 둘을 +# 1시간) · 포털 요청이 함께 버티게 (nginx.conf 의 http 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다. +# 연결마다 파일 하나(fd)를 쓰므로 compose 가 frontend 의 nofile 을 8192 로 둔다. 이미지의 nginx.conf 꼴이 바뀌어 고치지 못하면 빌드를 멈춘다 +RUN sed -i 's/^\([[:space:]]*worker_connections[[:space:]]\{1,\}\)[0-9]\{1,\};/\14096;/' /etc/nginx/nginx.conf \ + && grep -Eq '^[[:space:]]*worker_connections[[:space:]]+4096;' /etc/nginx/nginx.conf # 운영 이미지 — compose 는 target 없이 빌드하므로 이 단계가 파일의 마지막이어야 한다 FROM nginx-config AS production-stage diff --git a/frontend/nginx-api-errors.conf b/frontend/nginx-api-errors.conf new file mode 100644 index 0000000..5599614 --- /dev/null +++ b/frontend/nginx-api-errors.conf @@ -0,0 +1,48 @@ +# ========================================== +# /api 의 nginx 오류 응답 — 두 server(포털 · 공개 내려받기)가 넣는다 (nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/api-errors.conf) +# +# message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게). +# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다 +# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다). +# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(404·413·429·502·504 등)이 그대로 나간다 (2026-10-27 확인) +# ========================================== + +# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때). 없는 경로와 같은 RES_001 +location @api_not_found { + 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":"RES_001","message":"","traceId":"$request_id"}}'; +} + +# 본문 한도 초과 413 — 첨부 · 업로드 · 대용량 조각 · 공개 내려받기 server 의 /api/(1m) +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"}}'; +} + +# 동시 연결 수 초과 429 — 대용량 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001 +location @api_too_many_requests { + 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":"REQ_001","message":"","traceId":"$request_id"}}'; +} + +# 백엔드 응답 없음 · 시간 초과 등 5xx +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"}}'; +} diff --git a/frontend/nginx-env.envsh b/frontend/nginx-env.envsh index 50f9c91..3154c7b 100644 --- a/frontend/nginx-env.envsh +++ b/frontend/nginx-env.envsh @@ -64,4 +64,74 @@ esac # 도커 내장 DNS — 15-local-resolvers.envsh 가 /etc/resolv.conf 에서 읽어 둔다 (Dockerfile 의 NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1) NGINX_LOCAL_RESOLVERS="${NGINX_LOCAL_RESOLVERS:-127.0.0.11}" -export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS +# ------------------------------------------ +# 대용량 첨부의 공개 내려받기 호스트 (11단계 — docs/mail/api-contract.md 4절 "공개 호스트") +# +# SHARE_PUBLIC_URL(출처 — http(s)://호스트[:포트])의 호스트 이름을 포털(CORS_ORIGIN — 콤마로 여럿)의 호스트 이름들과 견준다. +# 백엔드(modules/file-sharing/file-sharing.settings.ts 의 dedicatedPublicHost)와 같은 판정이다 — 포트는 보지 않는다(nginx 는 포트 없는 Host 를 넘긴다): +# 다르면 공개 내려받기 server 의 이름 = 그 호스트 이름, 포털은 /s/ · /api/public-links/ 를 404 로 닫는다(share-closed · share-api-closed) +# 같거나 SHARE_PUBLIC_URL 이 비었으면 포털이 함께 연다(share-open · share-api-open), 공개 내려받기 server 는 어느 요청에도 맞지 않는 이름(share.invalid — RFC 2606) +# SHARE_PUBLIC_URL 의 모양이 틀리면 기동을 멈춘다 — 경로 · 공백 · ; 등이 server_name 에 들어가 설정을 깨거나 지시어를 끼워 넣지 않게. +# CORS_ORIGIN 의 모양은 백엔드가 본다(운영은 틀리면 백엔드가 뜨지 않는다) — 여기서는 읽을 수 있는 주소만 견준다. +# ------------------------------------------ + +# 출처 → 호스트 이름 (소문자, 포트 없이). 모양이 틀리면 아무것도 내지 않고 1 +nginx_env_origin_host() { + case "$1" in + http://*) nginx_env_hostport="${1#http://}" ;; + https://*) nginx_env_hostport="${1#https://}" ;; + *) return 1 ;; + esac + # 영문 · 숫자 · . - : 만 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) 를 받지 않는다 + case "$nginx_env_hostport" in + "" | *[!A-Za-z0-9.:-]*) return 1 ;; + esac + nginx_env_host="${nginx_env_hostport%%:*}" + nginx_env_port="${nginx_env_hostport#"$nginx_env_host"}" + case "$nginx_env_port" in + "") ;; + :0* | : | :*[!0-9]* | :??????*) return 1 ;; + esac + # 빈 이름 · 점으로 시작 · 끝(끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드와 어긋난다) · 연이은 점 · 하이픈으로 시작 · 끝 + case "$nginx_env_host" in + "" | .* | *. | *..* | -* | *-) return 1 ;; + esac + printf '%s' "$nginx_env_host" | tr 'A-Z' 'a-z' +} + +nginx_env_share_url="${SHARE_PUBLIC_URL:-}" +nginx_env_share_host="" +if [ -n "$nginx_env_share_url" ]; then + nginx_env_share_host="$(nginx_env_origin_host "$nginx_env_share_url")" \ + || nginx_env_fail "SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처(http(s)://호스트[:포트] — 경로 · 끝의 / · 끝 점 없이)여야 합니다 (예: https://files.arasystem.it.kr, 현재: \"$nginx_env_share_url\")." +fi + +nginx_env_same_host="" +if [ -z "$nginx_env_share_host" ]; then + nginx_env_same_host=1 +else + # 콤마로 나눠 하나씩 — 앞뒤 공백과 끝의 / 하나는 떼고 견준다 (백엔드도 출처로 고쳐 견준다) + while IFS= read -r nginx_env_origin; do + nginx_env_origin="$(printf '%s' "$nginx_env_origin" | tr -d ' ')" + nginx_env_origin="${nginx_env_origin%/}" + nginx_env_portal_host="$(nginx_env_origin_host "$nginx_env_origin")" || continue + if [ "$nginx_env_portal_host" = "$nginx_env_share_host" ]; then + nginx_env_same_host=1 + fi + done < (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기") +# +# 공개 내려받기 server 가 넣는다(nginx.conf). 공개 호스트를 따로 두지 않으면 포털 server 도 넣는다(nginx-env.envsh 의 NGINX_PORTAL_SHARE=open). +# 템플릿이 아니다 — /etc/nginx/snippets/share-open.conf 로 그대로 들어간다(Dockerfile). ${NGINX_*} 를 쓰지 않는다 — $page_csp 는 nginx.conf 의 http 단계. +# 공개 API(/api/public-links/)는 여기가 아니라 /api/ 블록 안의 nginx-share-api-open.conf 다(대소문자를 가리지 않게 — nginx.conf 머리말). +# 로그인 · 쿠키를 쓰지 않는 길이다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다. +# ========================================== + +# 화면 /s/<토큰> — SPA 의 공개 라우트(셸 · 로그인 없음 — 세션 복원을 건너뛴다). 파일은 토큰과 상관없이 index.html 하나다. +# server 단계의 보안 헤더를 물려받지 않고 여기서 다 쓴다 — 포털 server 에 들어갈 때(같은 호스트) 포털의 Referrer-Policy(strict-origin)와 +# 두 줄이 되지 않게. 포털 화면과 다른 것: Referer 를 아예 보내지 않는다(주소에 토큰이 있다 — 화면에서 나가는 요청 · 내려받기 이동에 실리지 않게), +# 검색 엔진에 올리지 않는다. index.html 이라 매번 재검증한다(배포 뒤 지워진 해시 자산을 부르지 않게 — 포털의 /index.html 과 같은 까닭) +# 대소문자를 가린다 — 화면 라우터도 /s/ 만 공개 화면으로 그린다(routes.ts 의 sensitive). /S/… 는 공개 내려받기 server 에서는 404, +# 포털에서는 SPA 의 다른 화면이다(공개 화면이 그려지지 않는다) +location ^~ /s/ { + root /usr/share/nginx/html; + try_files /index.html =404; + add_header_inherit off; + add_header Cache-Control "no-cache" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Content-Security-Policy $page_csp always; +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 852a32e..339643b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; default $request; } -map $http_referer $log_referer { +map $log_request_unqueried $log_request_pathmasked { + "~*^(?[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?.*)$" $log_path_head***$log_path_tail; + default $log_request_unqueried; +} + +map $log_request_pathmasked $log_request_tokenmasked { + "~^(?.*?)(?:(?.*)$" $log_token1_head***$log_token1_tail; + default $log_request_pathmasked; +} + +map $log_request_tokenmasked $log_request_line { + "~^(?.*?)(?:(?.*)$" $log_token2_head***$log_token2_tail; + default $log_request_tokenmasked; +} + +map $http_referer $log_referer_unqueried { "" "-"; "~^(?[^?]*)\?" $log_referer_head; default $http_referer; } +map $log_referer_unqueried $log_referer_pathmasked { + "~*^(?(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?.*)$" $log_referer_path_head***$log_referer_path_tail; + default $log_referer_unqueried; +} + +map $log_referer_pathmasked $log_referer { + "~^(?.*?)(?:(?.*)$" $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 문서는 이 정책을 상속하는데, +# 그 안에는 앱이 넣는