- OAuth 제공자 이메일 검증 미확인 차단(email_verified/is_email_verified 미검증 시 거부) - OAuth state CSRF 방지: 세션 없는 CookieStateStore(httpOnly 1회용 state 쿠키) 두 전략에 주입 - refresh 토큰 폐기: User.tokenVersion + refresh payload ver 검증, 로그아웃 시 일괄 무효화 - 파일 업로드 MIME 화이트리스트(html/svg 차단) + 다운로드 attachment·nosniff(저장형 XSS 차단) - 인증 라우트 throttle(login 10·signup 5·resend 3 /분) - 첨부 삭제 업로더/admin 한정 - 인증 토큰 로그 운영 환경 게이트(원문 토큰 미기록) - docs/security-review.md(감사 보고서) 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 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. 첨부 삭제 권한 확인 없음
removeFile 가 assertMember + 첨부-업무 소속만 확인하고 업로더/관리자 확인 없음 → 같은 저장소 멤버가 타인의 첨부(및 디스크 파일) 삭제 가능.
보완: 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에 사용자 입력req를escapeHtml()처리(현재 목업이라 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, Kakaokakao_account.is_email_verified. - H2 ✅ 세션 없는 구조라
CookieStateStore(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에store주입(새 의존성 0). 런타임은 실제 제공자 콜백으로만 최종 확인 가능. - H3 ✅
User.tokenVersion(int) 추가, refresh JWT payload 에ver서명,jwt-refresh.strategy가ver===tokenVersion검증(구토큰은 0 호환). logout 이revokeSession(refresh 검증 후 tokenVersion 증가) 으로 발급된 refresh 일괄 폐기. (완전 회전+재사용탐지는 후속.) - M1 ✅ 업로드
fileFilterMIME 화이트리스트(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. 프론트 변경 없음(전부 서버측).
남은 항목(C, LOW): L1~L6 하드닝(차기). 런타임 미검증: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요.