fix: 보안 점검 보완 — 소셜 로그인·세션·업로드 강화 (HIGH+MEDIUM)

- 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>
This commit is contained in:
2026-06-19 17:52:36 +09:00
parent 8766a4f5b6
commit 8efcacb6bb
16 changed files with 326 additions and 13 deletions
+15 -2
View File
@@ -13,6 +13,7 @@ import {
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import type { CookieOptions, Request, Response } from 'express';
import { AuthService, type SignupResult } from './auth.service';
import { SignupDto } from './dto/signup.dto';
@@ -93,6 +94,8 @@ export class AuthController {
@Post('signup')
@HttpCode(HttpStatus.CREATED)
// 가입 남용/메일 폭탄 방지 — IP당 분당 5회
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
@@ -103,6 +106,8 @@ export class AuthController {
@Post('resend-verification')
@HttpCode(HttpStatus.OK)
// 메일 폭탄 방지 — IP당 분당 3회
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@ApiOperation({ summary: '인증 메일 재발송' })
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
@@ -128,6 +133,8 @@ export class AuthController {
@Post('login')
@HttpCode(HttpStatus.OK)
// 무차별 대입 방지 — IP당 분당 10회
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: '로그인' })
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
@ApiResponse({
@@ -187,9 +194,15 @@ export class AuthController {
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
@ApiOperation({ summary: '로그아웃 (세션 폐기 + 인증 쿠키 제거)' })
@ApiResponse({ status: 200, description: '로그아웃 성공' })
logout(@Res({ passthrough: true }) res: Response): null {
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
// refresh 토큰 버전을 올려 발급된 refresh 토큰을 서버측에서 무효화(탈취 토큰 재사용 차단)
const cookies = req.cookies as Record<string, string> | undefined;
await this.authService.revokeSession(cookies?.refresh_token);
this.clearAuthCookies(res);
return null;
}
+32 -2
View File
@@ -9,6 +9,7 @@ import { MailService } from '../mail/mail.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import type { JwtPayload } from './types/jwt-payload';
// bcrypt 해시 라운드
const BCRYPT_ROUNDS = 10;
@@ -31,6 +32,8 @@ export interface SignupResult {
export interface OAuthProfile {
provider: string;
email: string | null;
// 제공자가 이메일 소유를 검증했는지 — 미검증이면 계정 매칭/생성을 거부(탈취/선점 방지)
emailVerified: boolean;
name: string;
avatarUrl?: string | null;
}
@@ -137,6 +140,14 @@ export class AuthService {
HttpStatus.BAD_REQUEST,
);
}
// 제공자가 이메일을 검증하지 않았으면 거부 — 미검증 이메일로 타인 계정 탈취/선점 방지
if (!profile.emailVerified) {
throw new BusinessException(
'BIZ_001',
'소셜 계정의 이메일이 인증되지 않았습니다. 제공자에서 이메일 인증을 완료한 뒤 다시 시도해 주세요.',
HttpStatus.FORBIDDEN,
);
}
let user = await this.userService.findByEmail(profile.email);
if (!user) {
user = await this.userService.createOAuthUser({
@@ -153,8 +164,10 @@ export class AuthService {
return UserService.toPublic(user);
}
// access/refresh 토큰 발급
// access/refresh 토큰 발급 — refresh 에는 현재 tokenVersion 을 함께 서명(폐기 검증용)
async issueTokens(user: PublicUser): Promise<TokenPair> {
const dbUser = await this.userService.findById(user.id);
const ver = dbUser?.tokenVersion ?? 0;
const accessToken = await this.jwtService.signAsync(
{ sub: user.id, email: user.email },
{
@@ -164,7 +177,7 @@ export class AuthService {
},
);
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id },
{ sub: user.id, ver },
{
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
@@ -174,6 +187,23 @@ export class AuthService {
return { accessToken, refreshToken };
}
// 세션 폐기 — refresh 토큰을 검증해 사용자의 tokenVersion 을 증가(발급된 모든 refresh 무효화).
// 로그아웃 시 호출. 무효/만료 토큰이면 폐기할 세션이 없으므로 조용히 무시한다.
async revokeSession(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(
refreshToken,
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') },
);
if (payload?.sub) {
await this.userService.incrementTokenVersion(payload.sub);
}
} catch {
// 무효/만료 refresh 토큰 — 무시
}
}
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
private async sendVerification(user: User): Promise<void> {
const rawToken = randomBytes(32).toString('hex');
@@ -0,0 +1,50 @@
import { randomBytes } from 'crypto';
import type { Request } from 'express';
// OAuth CSRF 방지용 state 저장소 — 세션을 쓰지 않는 무상태(JWT) 구조라
// passport-oauth2 의 기본 SessionStore 대신 httpOnly 쿠키에 1회용 난수 state 를 보관한다.
// 인가 시작 시 store() 로 쿠키 발급 + state 반환(인가 URL 에 부착), 콜백에서 verify() 로
// 쿠키와 제공자 반환 state 일치를 확인한다(불일치 시 인증 거부).
const STATE_COOKIE = 'oauth_state';
const STATE_TTL_MS = 10 * 60 * 1000; // 10분
type StoreCallback = (err: Error | null, state?: string) => void;
type VerifyCallback = (
err: Error | null,
ok?: boolean,
info?: { message: string },
) => void;
function cookieOptions() {
const isProd = process.env.NODE_ENV === 'production';
return {
httpOnly: true,
secure: isProd,
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: STATE_TTL_MS,
};
}
export class CookieStateStore {
// passport 버전에 따라 (req, cb) 또는 (req, meta, cb) 로 호출되므로 마지막 인자를 콜백으로 사용
store(req: Request, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as StoreCallback;
const state = randomBytes(16).toString('hex');
req.res?.cookie(STATE_COOKIE, state, cookieOptions());
cb(null, state);
}
verify(req: Request, providedState: string, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as VerifyCallback;
const cookies = req.cookies as Record<string, string> | undefined;
const expected = cookies?.[STATE_COOKIE];
// 1회용 — 검증 직후 쿠키 제거
req.res?.clearCookie(STATE_COOKIE, { path: '/api/auth' });
if (!expected || expected !== providedState) {
cb(null, false, { message: 'OAuth state 불일치' });
return;
}
cb(null, true);
}
}
@@ -1,8 +1,13 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type Profile } from 'passport-google-oauth20';
import {
Strategy,
type Profile,
type StrategyOptions,
} from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
@@ -13,12 +18,15 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
config: ConfigService,
private readonly authService: AuthService,
) {
super({
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
const options: StrategyOptions & { store: CookieStateStore } = {
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
store: new CookieStateStore(),
};
super(options);
}
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
@@ -30,9 +38,13 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
const email = profile.emails?.[0]?.value ?? null;
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
const avatarUrl = profile.photos?.[0]?.value ?? null;
// 구글이 이메일 소유를 검증했는지(email_verified)
const json = profile._json as { email_verified?: boolean } | undefined;
const emailVerified = json?.email_verified === true;
return this.authService.validateOrCreateOAuthUser({
provider: 'google',
email,
emailVerified,
name,
avatarUrl,
});
@@ -30,6 +30,11 @@ export class JwtRefreshStrategy extends PassportStrategy(
if (!user) {
throw new UnauthorizedException();
}
// 토큰 버전 불일치 = 로그아웃/비밀번호 변경으로 폐기된 토큰 → 거부.
// (버전 미포함 구토큰은 0 으로 간주해 기존 세션 호환)
if ((payload.ver ?? 0) !== user.tokenVersion) {
throw new UnauthorizedException();
}
return UserService.toPublic(user);
}
}
@@ -3,6 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type KakaoProfile } from 'passport-kakao';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
@@ -17,6 +18,8 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
store: new CookieStateStore(),
});
}
@@ -28,6 +31,8 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
): Promise<PublicUser> {
const account = profile._json?.kakao_account;
const email = account?.email ?? null;
// 카카오가 이메일 보유+검증을 확인했는지(is_email_verified)
const emailVerified = account?.is_email_verified === true;
const name =
account?.profile?.nickname ||
profile._json?.properties?.nickname ||
@@ -41,6 +46,7 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
return this.authService.validateOrCreateOAuthUser({
provider: 'kakao',
email,
emailVerified,
name,
avatarUrl,
});
@@ -4,4 +4,6 @@ export interface JwtPayload {
sub: string;
// 이메일 (access 토큰에만 포함)
email?: string;
// refresh 토큰 버전 (refresh 토큰에만 포함) — User.tokenVersion 과 일치해야 유효
ver?: number;
}
+5
View File
@@ -8,6 +8,9 @@ declare module 'passport-kakao' {
id: number;
kakao_account?: {
email?: string;
// 카카오 이메일 보유/검증 플래그 — 미검증 이메일은 신뢰하지 않는다
has_email?: boolean;
is_email_verified?: boolean;
profile?: {
nickname?: string;
profile_image_url?: string;
@@ -26,6 +29,8 @@ declare module 'passport-kakao' {
clientID: string;
clientSecret?: string;
callbackURL: string;
// passport-oauth2 호환 state 저장소(OAuth CSRF 방지)
store?: unknown;
}
export type VerifyCallback = (
+12 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// 메일 발송 서비스 (플레이스홀더) —
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다.
@@ -7,8 +8,18 @@ import { Injectable, Logger } from '@nestjs/common';
export class MailService {
private readonly logger = new Logger(MailService.name);
// 가입 인증 메일 — 사용자가 클릭할 인증 링크를 로그로 출력한다.
constructor(private readonly config: ConfigService) {}
// 가입 인증 메일 — 개발 환경에서만 인증 링크(원문 토큰 포함)를 로그로 출력한다.
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
if (isProd) {
this.logger.log(
`[메일 발송 - 가입 인증] 수신자=${to} (인증 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
);
return;
}
this.logger.log(
[
'',
+15 -2
View File
@@ -44,8 +44,10 @@ import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
import {
MAX_FILE_SIZE,
UPLOAD_DIR,
fileMimeFilter,
type UploadedDiskFile,
} from './upload.config';
import { BusinessException } from '../../common/exceptions/business.exception';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@@ -193,6 +195,7 @@ export class TaskController {
FileInterceptor('file', {
dest: UPLOAD_DIR,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: fileMimeFilter,
}),
)
@ApiConsumes('multipart/form-data')
@@ -204,6 +207,14 @@ export class TaskController {
@UploadedFile() file: UploadedDiskFile,
@CurrentUser() user: PublicUser,
): Promise<AttachmentResponse> {
// 파일 누락 또는 허용되지 않는 형식(fileFilter 가 걸러 file 미존재)
if (!file) {
throw new BusinessException(
'BIZ_001',
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
return this.taskService.addFile(repoId, user.id, taskId, file);
}
@@ -238,11 +249,13 @@ export class TaskController {
taskId,
fileId,
);
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res).
// attachment(다운로드 강제) + nosniff 로 브라우저 인라인 렌더링/스니핑 차단(저장형 XSS 방지)
res.setHeader('Content-Type', attachment.mime);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.sendFile(absolutePath);
}
+9 -1
View File
@@ -782,9 +782,16 @@ export class TaskService {
fileId: string,
): Promise<void> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertMember(repo.id, actingUserId);
const membership = await this.assertMember(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
const attachment = await this.getAttachmentOrThrow(task.id, fileId);
// 업로더 본인 또는 관리자만 삭제 가능 — 멤버라도 타인의 첨부는 지울 수 없음
if (
attachment.uploader?.id !== actingUserId &&
membership.roleType !== 'admin'
) {
throw new ForbiddenException();
}
const fileName = attachment.name; // 삭제 전 캡처
await this.attachmentRepo.remove(attachment);
await this.deleteFileFromDisk(attachment.storedName);
@@ -1109,6 +1116,7 @@ export class TaskService {
): Promise<Attachment> {
const attachment = await this.attachmentRepo.findOne({
where: { id: fileId, task: { id: taskId } },
relations: ['uploader'],
});
if (!attachment) {
throw new NotFoundException();
+34
View File
@@ -13,6 +13,40 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
// 첨부 1건당 최대 크기(25MB)
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
// 허용 MIME 화이트리스트 — 실행 가능/스크립트(예: text/html, image/svg+xml) 차단.
// 다운로드는 attachment + nosniff 로 제공하지만, 업로드 단계에서도 1차로 막는다(심층 방어).
export const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
// 이미지
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
// 문서
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// 압축
'application/zip',
'application/x-zip-compressed',
'application/x-7z-compressed',
'application/x-rar-compressed',
]);
// multer fileFilter — 허용 MIME 만 통과. 거부 시 파일이 저장되지 않아 컨트롤러가 400 처리.
export function fileMimeFilter(
_req: unknown,
file: { mimetype: string },
cb: (error: Error | null, acceptFile: boolean) => void,
): void {
cb(null, ALLOWED_MIME_TYPES.has(file.mimetype));
}
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
export interface UploadedDiskFile {
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
@@ -66,6 +66,11 @@ export class User {
@Column({ name: 'avatar_url', type: 'varchar', nullable: true })
avatarUrl!: string | null;
// refresh 토큰 버전 — refresh JWT payload 에 함께 서명되며, 로그아웃·비밀번호 변경 시 증가시켜
// 발급된 모든 refresh 토큰을 일괄 무효화한다(서버측 세션 폐기).
@Column({ name: 'token_version', type: 'int', default: 0 })
tokenVersion!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
+5
View File
@@ -146,6 +146,11 @@ export class UserService implements OnApplicationBootstrap {
});
}
// refresh 토큰 버전 증가 — 발급된 모든 refresh 토큰 무효화(로그아웃·비밀번호 변경 시)
async incrementTokenVersion(userId: string): Promise<void> {
await this.userRepository.increment({ id: userId }, 'tokenVersion', 1);
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {
+16 -2
View File
@@ -287,8 +287,22 @@
> **알려진 한계**: ① **런타임 미검증** — DB + 소셜 콘솔에 콜백 URL 등록 필요(코드 레벨까지). ② 메일은 **로그 출력**(실발송 SMTP 미구현). ③ **의존성 추가(passport-google-oauth20/passport-kakao)라 dev 컨테이너 `up -d --build -V` 필요.** ④ 소셜 계정은 이메일 기준 매칭(별도 providerId 컬럼 없음) — 동일 이메일이면 동일인으로 연동.
#### 8-5. 그 외(미착수)
- AI 에이전트 패널 실연동, 메일 실발송(SMTP).
#### 8-5. 보안 점검 + 보완 (배치 A+B) ✅ (2026-06-19)
전 작업분 대상 보안 감사(4영역 병렬) → `docs/security-review.md`. 서버측 기반은 견고(IDOR/권한상승/SQL인젝션/에러누출 없음), 최약점은 신규 소셜 로그인. 사용자 결정: **HIGH+MEDIUM 적용**, 조직 전체 읽기 모델은 의도된 설계로 유지.
- **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부.
- **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0).
- **H3** refresh 회전/폐기 부재 → `User.tokenVersion` + refresh payload `ver` 검증, **logout 이 tokenVersion 증가로 발급 refresh 일괄 폐기**(구토큰 0 호환).
- **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단).
- **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분).
- **M3** 첨부 삭제 업로더/admin 한정.
- **M4** 인증 토큰 로그 prod 게이트(원문 토큰 운영 로그 미기록).
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요). 남은 LOW 하드닝(L1~L6: 에이전트 escape·DTO 바운드·비번정책·그랜드페더링 컷오프·multer 매핑·CORS/Swagger prod)은 차기.
#### 8-6. 그 외(미착수)
- AI 에이전트 패널 실연동, 메일 실발송(SMTP), 보안 LOW 하드닝(L1~L6).
---
+100
View File
@@ -0,0 +1,100 @@
# 보안 점검 보고서 (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 ✅** `User.tokenVersion`(int) 추가, refresh JWT payload 에 `ver` 서명, `jwt-refresh.strategy``ver===tokenVersion` 검증(구토큰은 0 호환). **logout 이 `revokeSession`(refresh 검증 후 tokenVersion 증가)** 으로 발급된 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. 프론트 변경 없음(전부 서버측).
**남은 항목(C, LOW)**: L1~L6 하드닝(차기). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요.