Files
comrelay/docs/security-review.md
ttipo db6a80a6f4 feat: refresh 토큰 완전 회전 + 재사용 탐지
- RefreshSession(패밀리) 테이블 도입 — 한 로그인=1행(currentJti/prevJti/expiresAt)
- /auth/refresh 회전형으로 교체: 현재 jti→새 jti 회전, 옛 토큰 재제출 시 재사용 탐지→패밀리 폐기(401)
- 동시 갱신 오탐 방지: 직전 jti 15초 유예 + 프론트 useApi single-flight refresh
- 로그아웃=해당 세션만 폐기, revokeAllSessions(전 기기) 준비
- tokenVersion·jwt-refresh.strategy·JwtRefreshGuard 제거(refresh는 가드 없이 서비스 직접 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:57:01 +09:00

13 KiB

보안 점검 보고서 (2026-06-19)

지금까지 작업분(인증/저장소/멤버/업무/활동/알림 + 소셜로그인·이메일 인증)을 대상으로 한 보안 점검 결과와 보완 계획. 4개 영역 병렬 감사(권한·IDOR / 인증·세션·OAuth / 입력검증·실패처리 / 프론트·시크릿).

총평

서버측 기반은 대체로 견고하다. 전 엔드포인트 JwtAuthGuard, 전역 ValidationPipe(whitelist+forbidNonWhitelisted+transform), 파라미터 바인딩(SQL 인젝션 없음), 에러 누출 차단(catch-all→SYS_001), 쿠키 HttpOnly·env별 secure/sameSite, 토큰 해시 저장·select:false, 페이지네이션 상한(100), 경로 traversal 안전. IDOR/권한 상승은 발견되지 않음(업무·멤버·알림 전부 서버측 멤버십/역할 게이트, 교차 저장소 위조 불가).

가장 약한 표면은 신규 소셜 로그인 플로우다. 아래 HIGH 3건은 운영 노출 전 우선 처리 권장.

심각도 요약

# 심각도 항목 위치
H1 HIGH OAuth 제공자 이메일 검증 미확인 → 계정 탈취/선점 auth.service.ts validateOrCreateOAuthUser, google/kakao.strategy
H2 HIGH OAuth state 누락 → 로그인 CSRF(세션 고정) google/kakao.strategy
H3 HIGH refresh 토큰 회전/폐기 부재 → 탈취 시 14일 재사용, 로그아웃 후도 유효 auth.service/controller, jwt-refresh.strategy
M1 MEDIUM 파일 업로드 MIME 미제한 + 다운로드 inline → 저장형 XSS(SVG/HTML) task.controller 업로드/다운로드
M2 MEDIUM 인증 라우트 throttling 부족 → 로그인 무차별·메일 폭탄 main.ts/app.module, auth.controller
M3 MEDIUM 첨부 삭제에 업로더/관리자 확인 없음 → 타 멤버 첨부 삭제 task.service removeFile
M4 MEDIUM 인증 토큰이 로그·GET 쿼리로 노출(플레이스홀더 메일러) mail.service, auth.service
M5 MEDIUM 가입 이메일 열거(409 "이미 가입된 이메일") auth.service signup
L1 LOW AI 에이전트 터미널 v-html 사용자 입력 미이스케이프(목업, self-XSS) TaskDetailPage.vue:478
L2 LOW DTO 배열/문자열 길이 무제한(content/assigneeIds/checklist, UpdateRepo.description) task/repo DTO
L3 LOW 비밀번호 정책 약함(MinLength 8만), bcrypt rounds 10 signup.dto, auth.service
L4 LOW 그랜드페더링이 매 부팅 전수 검증(취약한 결합) user.service onApplicationBootstrap
L5 LOW multer 크기초과→일반 500(400 아님) upload.config / task.controller
L6 LOW CORS prod에 localhost:3000 고정, Swagger 항상 활성 main.ts

상세 + 보완 방안

H1. OAuth 제공자 이메일 검증 미확인 (계정 탈취/선점)

validateOrCreateOAuthUser이메일 문자열만으로 기존 계정 매칭/신규 생성하며, 전략이 제공자의 이메일 검증 플래그(Google email_verified, Kakao is_email_verified)를 확인하지 않는다.

  • (a) 공격자가 제공자에 피해자 이메일을 미검증 상태로 설정해 로그인 → 기존 계정 로그인. 특히 미인증 로컬 계정이면 코드가 emailVerified=true 로 승격까지 함(이메일 인증 우회 탈취).
  • (b) 공격자가 소유하지 않은 이메일로 소셜 가입 → emailVerified:true 행 생성, 실소유자는 영영 가입 불가. 보완: 각 전략에서 제공자 검증 플래그를 읽어 OAuthProfile.emailVerified 로 전달, 미검증이면 거부. 검증된 이메일일 때만 매칭/자동연동 허용(미인증 로컬 승격도 검증 이메일 한정).

H2. OAuth state 누락 (로그인 CSRF)

두 전략 모두 state 미사용 → 인가코드 플로우에 CSRF 보호 없음. 공격자가 자기 계정으로 시작한 콜백을 피해자에게 완성시키면 피해자 브라우저가 공격자 계정으로 로그인. 보완: 리다이렉트 직전 서명·짧은 수명 쿠키에 난수 state 저장 → 콜백에서 검증(또는 passport state:true + 세션 스토어). 가능하면 PKCE.

H3. refresh 토큰 회전/폐기 부재

refresh는 무상태 JWT로 회전·서버측 폐기목록이 없다. /auth/refresh 가 새 쌍을 재발급해도 이전 토큰은 14일 내내 유효, logout 은 쿠키만 지움(서버 무효화 X). 탈취 시 14일 재생, 비번 변경으로도 기존 세션 강제 종료 불가. 보완(실용안): User에 tokenVersion(int) 추가 → refresh payload에 포함, jwt-refresh.strategy에서 payload.ver === user.tokenVersion 검증, logout·비밀번호 변경 시 증가(전 세션 무효화). (완전 회전+재사용 탐지는 후속.)

M1. 파일 업로드 MIME 미제한 + 다운로드 inline

FileInterceptor 에 크기 제한(25MB)만 있고 fileFilter 없음. 다운로드가 저장 Content-Type + Content-Disposition: inline 으로 스트리밍 → text/html·image/svg+xml 업로드를 직접 URL로 열면 API 오리진에서 스크립트 실행(저장형 XSS). 보완: fileFilter 로 허용 MIME 화이트리스트(image/pdf/zip/office) + 다운로드를 attachment 로 강제 + 해당 라우트 X-Content-Type-Options: nosniff.

M2. 인증 라우트 throttling 부족

전역 한도만 존재(express-rate-limit 150/15분, ThrottlerGuard 100/60초). login/signup/resend-verification 에 라우트별 강화 한도 없음 → 분당 ~100회 비번 추측, 메일 폭탄(signup/resend) 가능. 보완: @Throttle 로 login ~5/분, resend/signup ~3/분 + 이메일별 쿨다운. (계정 잠금/CAPTCHA는 후속.)

M3. 첨부 삭제 권한 확인 없음

removeFileassertMember + 첨부-업무 소속만 확인하고 업로더/관리자 확인 없음 → 같은 저장소 멤버가 타인의 첨부(및 디스크 파일) 삭제 가능. 보완: attachment.uploader.id === actingUserId || roleType==='admin' 아니면 Forbidden.

M4. 인증 토큰 로그/GET 쿼리 노출

MailService원문 토큰 포함 전체 링크를 로거로 출력. 로그 접근자가 24h 내 인증 가로채기 가능. (현재는 dev 플레이스홀더라 로그 출력이 의도된 동작이나, 실 메일러 전환 전 처리 필요.) 보완: NODE_ENV!=='production' 일 때만 전체 링크 로그(dev 편의 유지), prod는 수신자만 로그. 실 메일러 도입 시 짧은 TTL.

M5. 가입 이메일 열거

signup이 존재 이메일에 409 고유 메시지 → 계정 존재 열거 가능(login·resend는 비노출 — 일관성 차이). 보완(트레이드오프): 메시지 유지하되 M2 throttle/CAPTCHA로 비용↑. (완전 비열거는 UX 충돌이라 보류 가능.)

L1~L6 (하드닝)

  • L1: TaskDetailPage.vue:478 에이전트 터미널 tool.body 에 사용자 입력 reqescapeHtml() 처리(현재 목업이라 self-XSS, 1줄 수정).
  • L2: assigneeIds/checklist/content@ArrayMaxSize, content 요소 @MaxLength(_,{each:true}), UpdateRepoDto.description@MaxLength(300).
  • L3: 서버측 비밀번호 복잡도(영문+숫자) @Matches + 최대 길이, bcrypt rounds 10→12.
  • L4: 그랜드페더링을 매부팅 전수 → 생성일 컷오프/일회성 플래그로 한정.
  • L5: multer LIMIT_FILE_SIZE 를 400/BIZ로 매핑.
  • L6: CORS의 localhost:3000 을 dev 한정, Swagger를 prod에서 비활성(또는 보호).

정상 확인(변경 불필요)

IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignment/역할상승 불가, SQL 인젝션 없음(파라미터 바인딩), 다운로드 경로 traversal 안전(UUID→DB 랜덤 storedName), 에러 스택 비노출(catch-all→SYS_001), 시크릿 비로그(Gitea 토큰/JWT/비번/쿠키), 프론트 번들 비밀 없음(VITE_만), .env 미커밋(.example만, 플레이스홀더), PublicUser/엔티티 select:false로 민감필드 차단, 알림/활동 v-html은 escapeHtml 적용, best-effort 부수효과 격리(활동/알림 실패가 본작업 안 깨뜨림), Redis/캐시 장애 크래시 안전, 페이지네이션 상한.

제품 확인 필요(코드 아님)

  • 조직 전체 읽기 모델: 인증 사용자는 모든 저장소·활동·업무를 READ 가능(단일 조직 의도). 저장소를 비공개로 가둘 의도면 읽기에도 멤버십 게이트 필요 — 의도 확인.

보완 배치(권장 순서)

  • A(HIGH·우선): H1 OAuth 이메일검증 + 안전연동 / H3 refresh tokenVersion 폐기 / H2 OAuth state CSRF
  • B(MEDIUM): M1 업로드 MIME+attachment+nosniff / M2 인증 throttle / M3 첨부삭제 권한 / M4 토큰로그 prod 게이트 / (M5는 M2로 완화)
  • C(LOW 하드닝): L1 에이전트 escape / L2 DTO 바운드 / L3 비번정책+bcrypt / L4 그랜드페더링 컷오프 / L5 multer 매핑 / L6 CORS·Swagger prod

적용 현황 (2026-06-19) — 배치 A+B 완료

사용자 결정: A+B 적용, 조직 전체 읽기 모델은 의도된 설계로 유지(읽기 게이트 미적용).

  • H1 각 전략이 제공자 이메일 검증 플래그를 읽어 OAuthProfile.emailVerified 전달, validateOrCreateOAuthUser 가 미검증이면 403 거부(검증 이메일일 때만 매칭/생성/미인증 로컬 승격). Google _json.email_verified, Kakao kakao_account.is_email_verified.
  • H2 세션 없는 구조라 CookieStateStore(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에 store 주입(새 의존성 0). 런타임은 실제 제공자 콜백으로만 최종 확인 가능.
  • H3 → 완전 회전+재사용 탐지로 강화(2026-06-19): 초기엔 tokenVersion 폐기 수준이었으나, 세션(패밀리) 테이블 기반 회전형 refresh 로 교체. RefreshSession(id=fid, currentJti, prevJti+prevAt, expiresAt) 한 로그인당 1행. /auth/refresh 가 현재 jti면 새 jti로 회전, 이미 회전된 옛 토큰 재제출이면 재사용 탐지 → 패밀리 전체 폐기(401). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight로 방지. logout=해당 패밀리만 폐기, revokeAllSessions(전 기기) 메서드 준비(비번 재설정 시 사용). tokenVersion/jwt-refresh.strategy/JwtRefreshGuard 제거(refresh는 가드 없이 서비스에서 직접 처리).
  • M1 업로드 fileFilter MIME 화이트리스트(html/svg 등 차단) + 거부 시 400, 다운로드 Content-Disposition: attachment + X-Content-Type-Options: nosniff.
  • M2 @Throttle 라우트별 — login 10/분, signup 5/분, resend 3/분(IP 기준). 계정 잠금/CAPTCHA·이메일별 쿨다운은 후속.
  • M3 removeFile 가 업로더 본인 또는 admin 만 허용(getAttachmentOrThrow 에 uploader 로드).
  • M4 MailService 가 prod 에서는 인증 링크(원문 토큰)를 로그에 남기지 않고 수신자만 기록(dev 는 링크 출력 유지).
  • M5 완화: M2 throttle 로 가입 열거 자동화 비용 상승(메시지는 UX 위해 유지).

검증: 백엔드 build/lint 0 · 20 tests pass. 프론트 변경 없음(전부 서버측).

적용 현황 (2026-06-19) — 배치 C (L2~L6) 완료, L1 보류

사용자 지시: 배치 C 진행하되 에이전트 관련(L1)은 아직 미작업이라 제외.

  • L1 ⏸ 보류: AI 에이전트 터미널 v-html escape (에이전트 패널 자체가 미연동 영역이라 사용자 요청으로 제외).
  • L2 DTO 길이 바운드 — task content @ArrayMaxSize(200)+요소 @MaxLength(5000), assigneeIds @ArrayMaxSize(50), checklist @ArrayMaxSize(100)(create/update 양쪽), UpdateRepoDto.description @MaxLength(300).
  • L3 비밀번호 정책 — @MaxLength(72)(bcrypt 72바이트 한계) + @Matches 영문·숫자 포함, bcrypt rounds 10→12.
  • L4 그랜드페더링을 컷오프(2026-06-20) 이전 생성 계정으로 한정(createdAt LessThan) — 이후 가입은 토큰 상태 무관하게 정상 인증.
  • L5 multer 오류(크기초과 등)를 전역 필터에서 400(VAL_001)로 매핑(기존 일반 500 → 명확한 검증 오류).
  • L6 CORS localhost:3000 을 비운영 한정, Swagger(/api-docs)를 비운영 한정 노출.

검증: 백엔드 build/lint 0 · 20 tests. 프론트 변경 없음.

남은 항목: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(토큰 완전회전 완료 / 계정잠금·CAPTCHA / M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). access 토큰 즉시 폐기(denylist)는 여전히 미적용(15분 잔존, 수용). 런타임 미검증: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요(특히 회전형 refresh의 동시성·재사용 탐지 동작).