# 보안 점검 보고서 (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`, 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의 동시성·재사용 탐지 동작).
