계약 1절 끝의 옛 메모(/api/push-devices · { minVersion, latestVersion, storeUrl })를 확정한 경로 · 모양 · 규칙으로 바꾸고
(N · 간격 · 따라잡기 · 엔진 after 의 초 경계 · "알림이 멈췄습니다"를 보내는 까닭 표 · 실패 처리 · 실기기로 확인할 것),
"클라이언트 — 앱"에 앱이 실제로 부르는 때를 적었다. 2절 실시간 알림의 붙잡기와 6절 퇴사 마무리 목록도 더했다.
nestjs · docker 스킬과 env · 보안 · 로그 규칙에 FCM 키 자리 · 바깥 연결 · 가릴 값을 더했다.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27 KiB
보안
비밀 정보
.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.md1절 "앱 로그인"), 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단계 켜기)만 지나고 그 밖은 403AUTH_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 을 고쳐 쓸 때는 떨어진 문서(
<template>·DOMParser) 안에서 끝낸다 — 화면 문서로 옮기면img onerror같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 (vue3 체크리스트 "외부 HTML")
파일 업로드
템플릿은 modules/upload 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- 확장자와 MIME 을 둘 다 검사한다 (
upload.config.ts). 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.- 예외 — 대용량 첨부(11단계
modules/file-storage)는 확장자 허용 목록만 본다. 브라우저가 주는 형식은 dwg · psd · 7z 같은 파일에 믿을 근거가 없어(비거나 제각각) MIME 으로 거르면 정상 파일만 막힌다. 대신 세 겹으로 막는다: 내려줄 때 늘application/octet-stream+attachment+nosniff+ CSPsandbox(브라우저가 열지 않는다), 허용 목록에 브라우저가 실행하는 형식이 없다, 내용은 ClamAV 가 본다(검사 전 · 중에는 받을 수 없다) — 계약 4절 "정한 것" 10.
- 예외 — 대용량 첨부(11단계
- 허용 목록(allowlist) 방식으로만 받는다. 차단 목록은 언제나 빠뜨린다.
.html·.svg·.js등 브라우저가 실행할 수 있는 형식은 넣지 않는다. - 저장 파일명은 서버가 생성한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
../../etc/passwd같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다. - 크기 한도를 설정한다 (
UPLOAD_MAX_SIZE_MB, 기본 5MB). 초과 시FILE_002(413). - 큰 업로드(메일 첨부 등)는 메모리가 아니라 임시 디스크로 받아 스트리밍으로 넘기고, 성공·실패와 관계없이 지운다. 메모리에 받으면 동시 업로드 수 × 한도만큼 상주해 컨테이너가 OOM 으로 죽고 모든 요청이 끊긴다. 임시 디스크를 tmpfs 로 두면 메모리 한도에 잡혀 같은 문제가 된다.
- 업로드 디렉터리를 정적 파일로 그대로 서빙하지 않는다. 내려줄 때는 컨트롤러를 거쳐
Content-Type을 서버가 지정하고Content-Disposition: attachment를 붙인다.
⚠️ 알려진 한계: 확장자·MIME 검사는 파일 내용을 보지 않는다. 이름이
.png이고 MIME 이image/png이면 내용이 HTML 이어도 통과한다. 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면 매직 바이트를 확인하는 라이브러리(file-type등)를 파이프라인에 추가한다. (서명 그림은 매직 바이트로 판별하고 —modules/mail-signature/signature-image-format.ts, 대용량 첨부는 악성 검사(ClamAV)가 내용을 본다.)
공개 링크 (로그인 없이 받는 길 — 11단계 대용량 첨부)
- 토큰은 서버가 만든 256비트(
randomBytes(32)base64url)이고 DB 에는 SHA-256 만 둔다. 모양이 아니면 DB 에 묻지 않는다. 원문 토큰은 로그 · 오류 context · 목록 응답 어디에도 두지 않는다 — 요청 주소는maskUrlForLog()(경로 어디서든public-links뒤 조각을 가린다), 앞단 nginx 접근 로그도 가린다. - 공개 경로는 따로 둔 공개 호스트(
SHARE_PUBLIC_URL—files.)에서만 열고 포털에서는 닫는다(nginx 의 호스트별 server + 백엔드public-host.ts두 겹). 인증 쿠키는 host-only 라 공개 호스트로 가지 않는다. 운영에서 공개 호스트를CORS_ORIGIN에 넣으면 기동하지 않는다(분리가 꺼지므로). - 받은 수 상한은 한 문장의 조건부 UPDATE(
… WHERE count < limit RETURNING)로 센다 — 읽고 나서 쓰면 동시 요청이 상한을 넘는다. - 버퍼링을 끈 내려받기는 연결 하나가 앞단 연결 둘과 백엔드 fd 를 잡는다 — IP 마다 · 전체 동시 연결 상한(nginx
limit_conn)을 함께 둔다. 전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다. 앞단과 백엔드가 같은 경로를 같게 읽어야 한다 — 백엔드가 대소문자를 가리지 않던 때/FILES/처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰). 그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — nestjs "전역 HTTP 설정"). 같은 까닭으로 요청 대상의 날\(경로) ·#· 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛url.parse로 다시 읽어\를/로 바꿔, nginx 가 닫은/api/internal/·/api/health가 백엔드에서 열렸다(2026-10-31 리뷰).
앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM)
- 글은 "새 메일 N통"뿐이다 — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(
modules/push/fcm/fcm-message.ts)에서 종류 · 개수만 받아 만든다(받는 칸이 없다), 새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계). - 휴대폰 주소(FID)는 한 세션에만 — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지.
- 보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다 — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의
revoked_at으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403AUTH_003). - 서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나 — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로,
backend/.env.{env}의FCM_SERVICE_ACCOUNT_KEY에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다). - 바깥 주소는 상수 두 곳(
oauth2.googleapis.com·fcm.googleapis.com)뿐이다 — 키 파일의token_uri를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다.
네트워크 · 인프라
- 보안 헤더(
helmet) 적용 - CORS 는 화이트리스트 방식. 차단 시
callback(null, false)로 헤더를 부여하지 않아 브라우저가 막게 한다 (callback(new Error(...))를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다) - 쿠키로 인증하므로 상태를 바꾸는 요청은 출처를 검사한다 (
OriginGuard, 전역 — 계약docs/mail/api-contract.md머리말). 비교 기준은Host가 아니라 허용 목록(CORS_ORIGIN)이다. 프록시 뒤에서는Host가 바뀌어 정상 요청까지 막힌다. - 클라이언트 IP 는
TRUST_PROXY(홉 수 또는 주소 목록)로만 해석한다. 모든 홉을 믿으면(true)X-Forwarded-For위조로 IP 기준 한도(로그인 실패·rate limit)가 무력화된다. 입구는 프론트 nginx 하나로 둔다 — 프록시를 건너뛰어 백엔드에 닿는 경로가 있으면 홉 수가 어긋나 위조가 가능해진다. - 인터넷을 향한 컨테이너(프론트 nginx)는 백엔드에만 닿게 한다 — DB·메일 엔진과 같은 네트워크에 두지 않는다
(
edge: frontend↔backend /app-network: backend↔db↔mail, docker). 메일 엔진의 신뢰 대역은app-network뿐이다. - 컨테이너가 쓰는 데이터(업로드 등)는 named volume 에 둔다 — 호스트 바인드 마운트는 root 소유로 생겨 비-root 컨테이너가 쓰지 못한다.
- Rate Limiting:
@nestjs/throttler하나로 일원화되어 있다 (app.module.ts의APP_GUARD). 초과 시 429 → 전역 필터가REQ_001로 매핑한다.- 기본 정책은 루트
.env의THROTTLE_TTL_MS/THROTTLE_LIMIT(미설정 시 60초/100회) - 라우트 단위 조정:
@Throttle({ default: { ttl, limit } })/ 제외:@SkipThrottle() - 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 더 엄격한 한도를 따로 건다
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
- 기본 정책은 루트
- 백업 비밀(13단계): restic 비밀번호 · Object Storage 키는 서버의
/etc/arasystem/(root 0600)에 두고 서버 밖(비밀번호 관리자)에도 둔다 — 잃으면 백업을 풀 수 없다. 백업에는 env 파일이 들어간다(암호화). 운영 스크립트는 비밀을docker exec -e 이름· 표준 입력 · 읽기 전용 마운트로만 넘기고 명령줄(ps로 보인다)에 싣지 않는다 — Healthchecks 주소도(curl -K -) - 컨테이너 root 실행 금지 — 개발 환경 포함. 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
- 프론트엔드 운영:
nginxinc/nginx-unprivileged+listen 8080(비-root 는 1024 미만 포트 바인드 불가) - Node 스테이지:
RUN chown node:node /app→USER node→COPY --chown=node:node(소유권을 먼저 넘겨야 익명 볼륨node_modules가 node 소유로 생성되어 캐시 쓰기가 가능하다) - 예외(13단계 — 호스트 systemd 의 백업 · 복원 보조 컨테이너): restic 은 다른 소유자(메일 엔진 UID 2000 등)의 볼륨을 읽으려고 root 로 돌되
--cap-drop ALL+DAC_READ_SEARCH·no-new-privileges만(복원은DAC_OVERRIDE·CHOWN·FOWNER를 더함). 앱 컨테이너가 아니라 운영 스크립트가 잠깐 띄우는 것이다 (docker "백업 · 감시")
- 프론트엔드 운영:
- DB·Redis 는
expose만 사용해 내부 네트워크에만 노출한다. 개발 편의로 호스트에 열어야 한다면 반드시127.0.0.1로 한정한다 —"${DB_PORT}:5432"는0.0.0.0바인딩이라 같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.ports: - "127.0.0.1:${DB_PORT}:5432" # ✅ # - "${DB_PORT}:5432" # ❌ 외부 네트워크 노출 - 보안 헤더는 CSP 까지 설정한다 (
frontend/nginx.conf). 외부 CDN·폰트·분석 스크립트를 추가하면 해당 도메인을 CSP 에 명시한다.script-src에'unsafe-inline'/'unsafe-eval'을 넣으면 CSP 의 의미가 사라진다. API 도메인이 다르면connect-src에 추가해야 요청이 차단되지 않는다.
의존성
- 의존성 추가 시 유지보수 상태를 확인하고, 주기적으로
npm audit/flutter pub outdated로 취약점을 점검한다 npm audit fix는 반드시 컨테이너 안에서 실행한다. 호스트(Windows)에서 실행하면 lock 파일이 깨진다 (nestjs)docker exec <프로젝트>-backend npm audit fix--force는 함부로 쓰지 않는다. 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다. 실행 전npm audit fix --force --dry-run으로 어떤 패키지가 올라가는지 확인한다.
남아 있는 취약점 판단 기준
모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 결정을 기록한다.
- 도달 가능한 코드 경로가 있는가 — 해당 기능을 쓰지 않으면 노출되지 않는다
- 수정이 다른 것을 깨뜨리는가 — 생태계 버전이 어긋나면 더 큰 위험
- 미룬다면 언제 다시 볼지 조건을 적어둔다
현재 유예 중인 항목 (2026-10-27 점검):
frontend
eslint-plugin-vue@9→postcss-selector-parser(moderate, 선택자 파싱 CPU 과다 사용)
- 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
- 미루는 이유: 수정판이
eslint-plugin-vue@10(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.- 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종):
항목 도달 경로 미루는 이유 다시 볼 시점 body-parser2.3.0 (high, 압축 본문 DoS)없음 — Nest 기본 파서를 끄고 JSON 파서만 inflate:false로 둔다. 압축 본문은 해제 전에 415 (실서버 확인)수정판 2.4.0 이 2026-10-22 배포 (2주 미만) 2026-11-05 에 lock 갱신 (express 5.3.0 이 ^2.3.0이라 범위 안)multer2.4.0 (high, 비동기 fileFilter DoS)없음 — 우리 fileFilter(upload·mail)는 모두 동기 2.5.0 이 2026-10-22 배포 (2주 미만). @nestjs/platform-express최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음2026-11-05 이후 override ^2.5.0여부 결정js-yaml5.3.0 (moderate,@nestjs/swagger하위)없음 — swagger 는 dump만 쓰고 우리 코드는 YAML 을 파싱하지 않는다swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override ^5.4.3검토 (swagger 12 가 5.x 를 쓰는 것이 근거)fast-uri3.1.8 (moderate, 개발 전용)없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 3.1.9 가 2026-10-19 배포 (2주 미만) 2026-11-02 sanitize-html2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx)없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) 2026-10-28 이후 2.18.1 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다. 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 (nestjs "의존성 추가/제거").
참고:
@nestjs/platform-express11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다.같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성), backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등). 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다). 예외: 우리에게 닿는 취약점을 고치는 보안 릴리스는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다 (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 docker). 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1).
전이 의존성 취약점은 overrides 로 끊는다
직접 의존성이 취약한 하위 패키지를 정확 버전으로 고정해 두면 npm audit fix 로는 해결되지 않는다.
상위 패키지의 메이저 업그레이드를 기다리지 말고 package.json 의 overrides 로 하위 패키지만 올린다.
// backend/package.json
"overrides": {
"multer": "^2.4.0"
}
실제 사례:
@nestjs/platform-express@11은multer를2.2.0으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.@nestjs/platform-express@12는multer@2.4.0을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다. v12 가 하는 일이 결국 multer 를 올리는 것이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
규칙:
- override 는 상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인하고 적용한다 (위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
- 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
- 적용 후 lint·테스트·빌드·런타임 기동까지 확인한다. override 는 npm 이 검증해 주지 않는다.
- 상위 패키지를 정식으로 올릴 때 override 를 제거한다. 남겨두면 나중에 버전이 거꾸로 고정된다.
환경변수 관리 규칙은 env-structure, 도메인별 적용은 nestjs · flutter 참조.