Files
rup/.claude/rules/security.md
T
ttipo 5331362215 docs(rules): 서버화에 맞춰 규칙·스킬 문서 갱신
구현한 구조를 규칙 SSOT 에 반영한다 — 다음 작업이 같은 패턴을 쓰도록.

- security: 모바일 리프레시 토큰은 secure storage(쿠키 저장소가 없다) — 대신 해시 저장·회전·재사용 탐지.
  소셜 토큰은 서버가 제공자에게 확인, 개발용 로그인은 운영에서 코드로 닫힘, 업로드 매직 바이트 검사·소유자 확인
- nestjs 스킬: 인증·회원·도메인 모듈·커서 헬퍼·공통 검증·snake_case 이름 규칙·아이 삭제 정리 리스너,
  새 라우트는 기본 로그인 필요(@Public 예외)·새 엔티티는 소유자 조건 조회
- flutter 스킬: DATA_MODE, 세션·토큰 갱신, 실패 알림·succeeded(), 서버 사진, 저장소 계약(Local·Api) 패턴
- env·응답 형식·에러 코드·테스트·체크리스트: DATA_MODE·소셜 키, keysetPaginate·fetchAllPages,
  도메인 코드 목록, 앱의 AUTH_002 흐름, 가짜 서버·라이브 점검 테스트
- vue3 스킬: 웹 로그인 연동 시 필요한 것 (지금 인증은 모바일용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:43:48 +09:00

9.5 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 사용 (키: jwt_token)
  • Access Token 만료는 짧게(15분~1시간), Refresh Token 은 웹이면 HttpOnly 쿠키로만 전달
    • 모바일 앱은 예외 — 앱에는 쿠키 저장소가 없어 리프레시 토큰을 응답 본문으로 받아 flutter_secure_storage(키: refresh_token)에 둔다. 대신 서버는 리프레시 토큰을 해시로만 저장하고, 한 번 쓰면 폐기(회전) 하며, 폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (modules/auth)
  • 소셜 로그인은 앱이 보낸 사용자 id 를 믿지 않는다 — 서버가 토큰을 제공자에게 직접 확인한다 (카카오는 앱 ID 까지 대조)
  • 개발용 로그인(/auth/dev-sessions)은 운영에서 코드로 닫힌다 — env 값으로 열 수 없다
  • 비밀번호는 단방향 해시(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 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. 저장한 뒤 파일 앞부분(매직 바이트)도 확인해 형식과 다르면 지운다.
  • 파일은 올린 회원의 것으로 기록하고, 내려받기(GET /files/:id)·다른 기록에서의 참조 모두 본인 파일만 허용한다.
  • 허용 목록(allowlist) 방식으로만 받는다. 차단 목록은 언제나 빠뜨린다. .html·.svg·.js 등 브라우저가 실행할 수 있는 형식은 넣지 않는다.
  • 저장 파일명은 서버가 생성한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면 ../../etc/passwd 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
  • 크기 한도를 설정한다 (UPLOAD_MAX_SIZE_MB, 기본 5MB). 초과 시 FILE_002(413).
  • 업로드 디렉터리를 정적 파일로 그대로 서빙하지 않는다. 내려줄 때는 컨트롤러를 거쳐 Content-Type 을 서버가 지정하고 Content-Disposition: attachment 를 붙인다.

⚠️ 알려진 한계: 매직 바이트는 파일의 앞부분만 본다. 앞은 PNG 이고 뒤에 다른 내용을 덧붙인 파일은 통과한다. 그래서 위의 "정적 서빙 금지"(attachment + 서버가 정한 Content-Type + nosniff)가 여전히 중요하다.

네트워크 · 인프라

  • 보안 헤더(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 에 접속할 수 있다.
    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 으로 어떤 패키지가 올라가는지 확인한다.

남아 있는 취약점 판단 기준

모든 취약점을 즉시 올릴 수 있는 것은 아니다. 다음을 근거로 판단하고 결정을 기록한다.

  1. 도달 가능한 코드 경로가 있는가 — 해당 기능을 쓰지 않으면 노출되지 않는다
  2. 수정이 다른 것을 깨뜨리는가 — 생태계 버전이 어긋나면 더 큰 위험
  3. 미룬다면 언제 다시 볼지 조건을 적어둔다

현재 유예 중인 항목 (backend, 2026-10-11 기준 npm audit moderate 20건): js-yaml@3·sprintf-js — ts-jest → @jest/transform → babel-plugin-istanbul 안의 테스트 커버리지 도구 체인.

  1. 도달 경로: 운영 이미지(npm ci --omit=dev)에 들어가지 않고, 테스트가 읽는 YAML 도 저장소의 설정 파일뿐이다
  2. 수정 비용: 같은 메이저에 고친 버전이 없고 --force 는 ts-jest 를 메이저로 내린다
  3. 다시 볼 때: jest·ts-jest 메이저를 올릴 때, 또는 istanbul 이 js-yaml 4+ 로 옮겨 가면

전이 의존성 취약점은 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 로 같은 결과를 생태계 업그레이드 없이 얻었다.

같은 이유로 @nestjs/swagger@11 이 고정한 js-yaml@5.3.0(moderate)은 "@nestjs/swagger": { "js-yaml": "^5.4.2" } 로 올렸다 (근거: @nestjs/swagger@12 가 js-yaml@5.4.2 를 선언한다). swagger 를 v12 로 올릴 때 함께 제거한다.

규칙:

  • override 는 상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인하고 적용한다 (위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
  • 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
  • 적용 후 lint·테스트·빌드·런타임 기동까지 확인한다. override 는 npm 이 검증해 주지 않는다.
  • 상위 패키지를 정식으로 올릴 때 override 를 제거한다. 남겨두면 나중에 버전이 거꾸로 고정된다.

환경변수 관리 규칙은 env-structure, 도메인별 적용은 nestjs · flutter 참조.