# 보안 ## 비밀 정보 - `.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` 사용 (키: `jwt_token`) - Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달 - 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지 - 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001) ## 입력 · 출력 - 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다 - TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수) - 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]]) ### 파일 업로드 템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다. - **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. - **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다. `.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.** - **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면 `../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다. - 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413). - 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐 `Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다. > ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.** > 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다. > 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면 > 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다. ## 네트워크 · 인프라 - 보안 헤더(`helmet`) 적용 - CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다 (`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다) - 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 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다 - **컨테이너 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 소유로 생성되어 캐시 쓰기가 가능하다) - DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다. 개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라 같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다. ```yaml 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]]) ```bash docker exec <프로젝트>-backend npm audit fix ``` - **`--force` 는 함부로 쓰지 않는다.** 메이저 버전을 올려 다른 패키지와의 호환이 깨질 수 있다. 실행 전 `npm audit fix --force --dry-run` 으로 어떤 패키지가 올라가는지 확인한다. ### 남아 있는 취약점 판단 기준 모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 **결정을 기록**한다. 1. **도달 가능한 코드 경로가 있는가** — 해당 기능을 쓰지 않으면 노출되지 않는다 2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험 3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다 > **현재 유예 중인 항목** (2026-11-03 backend `npm audit` 36건: 보통 33 · 낮음 3, 높음·치명 0) > > | 항목 | 경로 | 도달 가능성 | 미루는 이유 | 다시 볼 조건 | > | --- | --- | --- | --- | --- | > | `glob`·`minimatch` (보통) | typeorm(엔티티·마이그레이션 경로 탐색), jest·eslint·@nestjs/cli | 패턴이 코드에 고정된 값이라 외부 입력이 닿지 않는다. 나머지는 개발 도구 | 수정판이 typeorm 1.x 등 메이저 업그레이드뿐 | typeorm 1.x 전환 · jest/eslint 메이저 업그레이드 때 | > | `deepmerge` · `jest` (낮음) | @nestjs/cli, jest | 개발 도구 전용(운영 이미지에 없음) | 수정판이 메이저 업그레이드뿐 | @nestjs/cli 12 · jest 다음 메이저 때 | ### 전이 의존성 취약점은 overrides 로 끊는다 직접 의존성이 취약한 하위 패키지를 **정확 버전으로 고정**해 두면 `npm audit fix` 로는 해결되지 않는다. 상위 패키지의 메이저 업그레이드를 기다리지 말고 `package.json` 의 `overrides` 로 하위 패키지만 올린다. ```jsonc // backend/package.json "overrides": { "multer": "^2.5.0", "@nestjs/swagger": { "js-yaml": "^5.4.3" } // 특정 패키지 아래에서만 올릴 때 } ``` > 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다. > `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다. > **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다. > > 2026-11-03 갱신: `multer@2.4.0` 에도 DoS 취약점(high)이 나와 `^2.5.0` 으로 올렸다(platform-express 12.3.2 가 2.5.0 을 선언). > `@nestjs/swagger@11.4.7` 은 `js-yaml@5.3.0`(보통)을 고정한다. swagger 12.1.0 이 5.5.0 을 선언하므로 swagger 아래에서만 올렸다 > — 최상위로 올리면 `js-yaml@4` 를 쓰는 eslint 까지 메이저가 바뀐다. 규칙: - override 는 **상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인**하고 적용한다 (위 사례는 platform-express v12 가 2.5.0 을, swagger 12.1.0 이 js-yaml 5.5.0 을 선언하는 것이 근거). - 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다. - 적용 후 **lint·테스트·빌드·런타임 기동까지 확인**한다. override 는 npm 이 검증해 주지 않는다. - 상위 패키지를 정식으로 올릴 때 **override 를 제거**한다. 남겨두면 나중에 버전이 거꾸로 고정된다. > 환경변수 관리 규칙은 [[env-structure]], 도메인별 적용은 [[nestjs]] · [[flutter]] 참조.