feat(docker): 12단계 계정 보안 — 엔진 웹훅 · /api/internal 닫기 · 요청 대상 400

메일 프로그램(IMAP · SMTP) 로그인 기록을 엔진 웹훅으로 받는다.
- 루트 env MAIL_WEBHOOK_SECRET(setup · backend 공용, 운영 필수)과 backend 의 APP_NETWORK_SUBNET
- setup 이 엔진 웹훅을 맞추고(표시 머리글 — 소금 + 키 지문) 서명한 빈 묶음으로 받는 곳을 확인한다
  키를 바꾼 실행 · 게이트웨이 OTP · 테스트 직원의 402 를 따로 다룬다
- nginx 두 server 가 /api/internal/ 을 닫고, 요청 대상의 날 \(경로) · # · 0x80 이상은 location 전에 400
  Express 가 이런 대상을 다시 읽어 닫은 경로(/api/internal · /api/health)가 백엔드에서 열렸다(2026-10-31 리뷰)
- CI "닫는 경로 시험"(두 server × 세 경로의 변형) · 지시어만 세는 정적 검사
- mail/README: 웹훅 · 12단계 전부터 운영 중인 서버를 올리는 차례 · 휴대폰을 잃은 직원(관리 API 명령)
엔진 DNS 가 Custom(운영)이어도 웹훅은 backend 를 푼다 — 메일 컨테이너가 도커 DNS 를 쓴다(개발에서 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:45:23 +09:00
parent c31442e639
commit d1ef0da488
11 changed files with 739 additions and 19 deletions
+76
View File
@@ -189,6 +189,16 @@ jobs:
'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
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
for form in plain rendered; do
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; 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
@@ -279,6 +289,72 @@ jobs:
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; }
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
- name: 닫는 경로 시험
run: |
name="ci-nginx-paths-${{ github.run_id }}"
docker exec -i "$name" sh -s <<'EOF'
fails=0
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
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'; [ -n "$WAIT" ] && sleep 1; } \
| 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 : "-") }'
}
# 찍을 때 0x80 이상 바이트는 ~ 로
check() {
label=$(printf '%s' "$2" | tr '\200-\377' '~')
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
}
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
BS=$(printf '\')
NB=$(printf '\240')
E=mail-engine-events
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
F=11111111-2222-4333-8444-555555555555
for host in portal.ci.test files.ci.test; do
echo "== $host — /api/internal 은 404"
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
expect "404 application/json RES_001" "$host" POST "$path"
done
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
expect "400 application/json VAL_001" "$host" POST "$path"
done
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
done
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
echo "fails=$fails"
[ "$fails" -eq 0 ]
EOF
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —