# 보안 ## 비밀 정보 - `.env*` 등 민감정보 Git 커밋 금지 (`.gitignore` 확인) — 예시는 `*.example`(예: `.env.development.example`, `.env.example`) 로만 공유 - **`*.example` 파일의 값은 반드시 더미 placeholder 여야 한다.** 실제로 쓰는(또는 쓸 법한) 비밀번호·키를 예시 파일에 적지 않는다. - ❌ `DB_PASSWORD=dev_960426` - ✅ `DB_PASSWORD=change-me-in-local-env` - 시크릿이 한 번이라도 커밋되었다면 **값을 교체(rotate)** 한다. 커밋을 되돌리는 것만으로는 노출이 해소되지 않는다. - 로그·에러 응답에 시크릿을 남기지 않는다 ([[logging]]). ## 인증 · 토큰 - JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔 - Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다) - 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다. 동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게) - 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다. 확인 뒤에 세면 동시에 들어온 요청 수백 건이 모두 한도 검사를 통과한다 (구현 [[nestjs]]) - 비밀번호는 단방향 해시(`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`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다 - **제한된 세션**(13단계 — 새 비밀번호 정하기 · 2단계 인증 켜기 전): 로그인은 되지만 허용한 경로(`@AllowRestrictedSession` — `/api/auth/*` · 2단계 켜기)만 지나고 그 밖은 403 `AUTH_010` 이다. 제한을 푸는 요청은 **그 요청의 세션만** 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(`twoFactorSetup`) 세션은 끊는다 — 비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다 - **운영 도구**(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(`historySize: 0` — 2026-11-02 리뷰). 표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다. 이전용 비밀번호(메일 이전 — 2026-11-02)도 같은 무작위 · 한 번만이고 엔진 만료를 30일 뒤로 건다(운영 도구 한 번 실행 안에서는 같은 값 — 프로세스 메모리에만) — imapsync 의 비밀번호 파일(`--passfile` — 0600, 끝나면 지움)에만 넣고 명령줄에 싣거나 직원에게 주지 않는다. 마지막 imapsync 뒤 「이전 마치기」로 직원용 임시 비밀번호로 바꾼다 - **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다. 되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄) - **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다 - **퇴사 마무리**(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다. 끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron) - 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004) - 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다. 로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다. ## 입력 · 출력 - 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다 - JSON 본문은 DTO 에 닿기 전에 **모양부터 잰다** — 배열 하나 1,000개 · 값 10,000개 · 깊이 32단, UTF-8 만 (`app.setup.ts` 의 `JSON_SHAPE_LIMITS`, `shared/utils/json-shape.ts`). DTO 의 개수 한도로는 막지 못한다 — 변환이 항목마다 객체를 만들고, 검사는 개수 한도에 걸려도 항목마다 중첩 검사를 해서 1MB 본문 하나로 서버가 수 초 멈췄다 (2026-10-28 리뷰). class-validator 의 `ArrayUnique` 는 항목 수의 제곱으로 느려 쓰지 않는다 (`common/decorators/array-distinct.decorator.ts` 의 `ArrayDistinct`) - TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수) - 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 **받는 쪽이 모르는 비밀로 HMAC** 한다 (예: 메일마다 다른 무작위 `Idempotency-Key`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다. - **서버 서명 키(`JWT_SECRET` 등)나 거기서 파생한 키를 쓰지 않는다** — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로 서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰) - 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]]) - 사용자가 쓴 HTML(메일 서식 본문)은 **서버가 허용 목록으로 다시 정화**한 뒤 내보낸다 — 클라이언트 편집기의 출력을 믿지 않는다. style 은 속성마다 값 형식을 처음부터 끝까지(`^…$`) 맞춘다 (`modules/mail/html/sanitize-outgoing-html.ts`, 계약 2절 "서식 본문") - 받은 메일·서식 본문처럼 **밖에서 온 HTML 은 정화하기 전에 비용부터 잰다** — 정화기(htmlparser2)는 중첩 깊이의 제곱으로 느려져 메일 한 통으로 서버 전체를 수 분 멈출 수 있다 (2026-10-27 리뷰). 넘으면 정화하지 않는다 (`modules/mail/html/html-parse-budget.ts`, [[nestjs]]) - 브라우저에서 외부 HTML 을 고쳐 쓸 때는 떨어진 문서(`