docs(rules): 12단계 부품 — 계정 보안 · 요청 대상 검사 · 엔진 웹훅 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m47s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 12m57s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 7m45s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 1m17s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m12s
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled

security: 2단계 인증 · 앱 비밀번호 · 끊은 세션 바로 막기 · 엔진이 부르는 길(세 겹) · 요청 대상의 날 \ · #.
nestjs · vue3: 골격 표에 계정 보안 행, 확장 때 옮길 메모리 상태, 엔진 Basic 402 는 장애가 아니다.
docker · env-structure · checklist: MAIL_WEBHOOK_SECRET · /api/internal 닫기 · 닫는 경로 시험 · setup 의 웹훅.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-10-31 17:45:24 +09:00
parent 9a72ce91e1
commit 781ffb2e16
6 changed files with 49 additions and 3 deletions
+1
View File
@@ -14,6 +14,7 @@
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
대용량 첨부를 쓰면 운영은 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 포털과 다른 호스트, `CORS_ORIGIN` 에 넣지 않는다)과 `CLAMAV_HOST=clamav` 도 필수다.
그 호스트의 DNS · TLS · 외부 프록시 Proxy Host(같은 frontend:8080, 접근 로그 끔)를 함께 만든다 ([[docker]] "외부 리버스 프록시 요구사항")
12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
+7 -2
View File
@@ -37,8 +37,11 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
│ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
│ MAIL_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상)도 setup · backend 가 함께 읽는다 — 운영 필수, 바꾸면 setup 다시 + backend 다시 띄우기
│ backend 에는 APP_NETWORK_SUBNET 도 넘긴다(웹훅 받는 쪽이 접속 주소가 앱 네트워크인지 본다)
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
BACKEND_PORT · MAIL_WEBHOOK_SECRET 도 읽는다(12단계 — 엔진 웹훅 주소 http://backend:<BACKEND_PORT>/… 와 서명 키, 셸 값과 다르면 멈춘다)
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
@@ -76,7 +79,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부),
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로)
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로),
`MAIL_WEBHOOK_SECRET`(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상. 예시 값 `CHANGEME…` 는 setup · backend 모두 거부)
- 운영 환경: **`SHARE_PUBLIC_URL` 의 호스트를 `CORS_ORIGIN` 에 넣지 않는다** — 같은 호스트면 공개 호스트 분리(포털에서 공개 링크를 닫는 것)가 꺼져 기동을 막는다.
`SHARE_PUBLIC_URL` 의 모양은 백엔드와 nginx 템플릿(`nginx-env.envsh`)이 같은 규칙으로 본다 — 출처만(경로 · 끝의 `/` · 끝 점 없이). 한 번 정하면 바꾸지 않는다(보낸 메일의 링크가 그 주소다)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
@@ -96,6 +100,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)·
> `MAIL_WEBHOOK_SECRET`(비밀 — 엔진 웹훅 서명 키, setup · backend 공용, 운영 필수 — 만들기 `openssl rand -hex 24`)
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
+11
View File
@@ -20,6 +20,15 @@
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
- **2단계 인증(12단계 — 엔진 TOTP)**: 엔진은 등록 때 코드를 확인하지 않고(잘못 등록하면 직원이 끄지도 못한다) 같은 코드를 다시 받으므로 서버가 막는다 —
등록 때 첫 코드 확인, 성공에 쓴 코드를 2분 동안 다시 받지 않기(확인하는 동안에도 — 같은 코드의 동시 요청이 둘 다 지나가지 않게), 코드 실패를 따로 세기(같은 주소 15분 5회 · 24시간 20회).
코드 단계에서도 비밀번호를 먼저 엔진 Basic 으로 본다 — 엔진 `/api/auth` 의 failure 는 비밀번호 · 코드를 가르지 않아, 그대로면 비밀번호 추측이 코드 실패로 세진다. 비밀(TOTP 키)은 등록 10분 동안만 서버 메모리에 두고 켠 뒤에는 엔진에만 있다
- **앱 비밀번호**(메일 프로그램용 — 엔진이 만든 40자): 웹메일 로그인 · 비밀번호 바꾸기는 그 모양(`app` + 1자 + 36자)을 엔진에 묻지 않고 거절한다(엔진에는 웹메일에서 빼는 설정이 없다).
만들 때 권한을 메일 프로그램만(`Replace` — authenticate · imapAuthenticate · emailSend)으로 좁힌다 — 기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만든다.
비밀번호를 바꾸면 앱 비밀번호를 모두 지운다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게). 값은 만든 응답에만 싣는다(no-store — 로그 · DB 에 두지 않는다)
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
@@ -79,6 +88,8 @@
전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다.
**앞단과 백엔드가 같은 경로를 같게 읽어야 한다** — 백엔드가 대소문자를 가리지 않던 때 `/FILES/` 처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰).
그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔,
nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰).
## 네트워크 · 인프라