From 8efcacb6bb72a5486fd7cf2a9fa439f7a1ea83b7 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 17:52:36 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84=20=E2=80=94=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7=EC=84=B8=EC=85=98=C2=B7?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B0=95=ED=99=94=20(HIGH+MEDIUM?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/src/modules/auth/auth.controller.ts | 17 ++- backend/src/modules/auth/auth.service.ts | 34 +++++- .../auth/strategies/cookie-state.store.ts | 50 +++++++++ .../auth/strategies/google.strategy.ts | 18 +++- .../auth/strategies/jwt-refresh.strategy.ts | 5 + .../modules/auth/strategies/kakao.strategy.ts | 6 ++ backend/src/modules/auth/types/jwt-payload.ts | 2 + .../modules/auth/types/passport-kakao.d.ts | 5 + backend/src/modules/mail/mail.service.ts | 13 ++- backend/src/modules/task/task.controller.ts | 17 ++- backend/src/modules/task/task.service.ts | 10 +- backend/src/modules/task/upload.config.ts | 34 ++++++ .../src/modules/user/entities/user.entity.ts | 5 + backend/src/modules/user/user.service.ts | 5 + docs/integration-progress.md | 18 +++- docs/security-review.md | 100 ++++++++++++++++++ 16 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/auth/strategies/cookie-state.store.ts create mode 100644 docs/security-review.md diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index f496097..af0a72b 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 { @@ -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 { + // refresh 토큰 버전을 올려 발급된 refresh 토큰을 서버측에서 무효화(탈취 토큰 재사용 차단) + const cookies = req.cookies as Record | undefined; + await this.authService.revokeSession(cookies?.refresh_token); this.clearAuthCookies(res); return null; } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index b4b712c..e6bda46 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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('JWT_REFRESH_SECRET'), expiresIn: (this.config.get('JWT_REFRESH_TTL') ?? @@ -174,6 +187,23 @@ export class AuthService { return { accessToken, refreshToken }; } + // 세션 폐기 — refresh 토큰을 검증해 사용자의 tokenVersion 을 증가(발급된 모든 refresh 무효화). + // 로그아웃 시 호출. 무효/만료 토큰이면 폐기할 세션이 없으므로 조용히 무시한다. + async revokeSession(refreshToken: string | undefined): Promise { + if (!refreshToken) return; + try { + const payload = await this.jwtService.verifyAsync( + refreshToken, + { secret: this.config.getOrThrow('JWT_REFRESH_SECRET') }, + ); + if (payload?.sub) { + await this.userService.incrementTokenVersion(payload.sub); + } + } catch { + // 무효/만료 refresh 토큰 — 무시 + } + } + // 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송 private async sendVerification(user: User): Promise { const rawToken = randomBytes(32).toString('hex'); diff --git a/backend/src/modules/auth/strategies/cookie-state.store.ts b/backend/src/modules/auth/strategies/cookie-state.store.ts new file mode 100644 index 0000000..0a4cf58 --- /dev/null +++ b/backend/src/modules/auth/strategies/cookie-state.store.ts @@ -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 | 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); + } +} diff --git a/backend/src/modules/auth/strategies/google.strategy.ts b/backend/src/modules/auth/strategies/google.strategy.ts index eb889c0..bf4045b 100644 --- a/backend/src/modules/auth/strategies/google.strategy.ts +++ b/backend/src/modules/auth/strategies/google.strategy.ts @@ -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('GOOGLE_CLIENT_ID'), clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'), callbackURL: config.getOrThrow('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, }); diff --git a/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts b/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts index cf97822..18488d9 100644 --- a/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts @@ -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); } } diff --git a/backend/src/modules/auth/strategies/kakao.strategy.ts b/backend/src/modules/auth/strategies/kakao.strategy.ts index 85a0c46..ee5311f 100644 --- a/backend/src/modules/auth/strategies/kakao.strategy.ts +++ b/backend/src/modules/auth/strategies/kakao.strategy.ts @@ -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('KAKAO_CLIENT_ID'), clientSecret: config.get('KAKAO_CLIENT_SECRET') ?? '', callbackURL: config.getOrThrow('KAKAO_CALLBACK_URL'), + // store: 쿠키 기반 state 저장소(OAuth CSRF 방지) + store: new CookieStateStore(), }); } @@ -28,6 +31,8 @@ export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { ): Promise { 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, }); diff --git a/backend/src/modules/auth/types/jwt-payload.ts b/backend/src/modules/auth/types/jwt-payload.ts index 67918ab..7f4a9aa 100644 --- a/backend/src/modules/auth/types/jwt-payload.ts +++ b/backend/src/modules/auth/types/jwt-payload.ts @@ -4,4 +4,6 @@ export interface JwtPayload { sub: string; // 이메일 (access 토큰에만 포함) email?: string; + // refresh 토큰 버전 (refresh 토큰에만 포함) — User.tokenVersion 과 일치해야 유효 + ver?: number; } diff --git a/backend/src/modules/auth/types/passport-kakao.d.ts b/backend/src/modules/auth/types/passport-kakao.d.ts index be0b887..81dfa94 100644 --- a/backend/src/modules/auth/types/passport-kakao.d.ts +++ b/backend/src/modules/auth/types/passport-kakao.d.ts @@ -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 = ( diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 8810637..b865a8b 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -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('NODE_ENV') === 'production'; + if (isProd) { + this.logger.log( + `[메일 발송 - 가입 인증] 수신자=${to} (인증 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`, + ); + return; + } this.logger.log( [ '', diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index 8d3fd2f..e5981b2 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -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 { + // 파일 누락 또는 허용되지 않는 형식(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); } diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 528b94f..5845cf8 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -782,9 +782,16 @@ export class TaskService { fileId: string, ): Promise { 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 { const attachment = await this.attachmentRepo.findOne({ where: { id: fileId, task: { id: taskId } }, + relations: ['uploader'], }); if (!attachment) { throw new NotFoundException(); diff --git a/backend/src/modules/task/upload.config.ts b/backend/src/modules/task/upload.config.ts index 439f876..6308acc 100644 --- a/backend/src/modules/task/upload.config.ts +++ b/backend/src/modules/task/upload.config.ts @@ -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 = new Set([ + // 이미지 + '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 인코딩으로 들어옴) diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index cf7ed7c..037f2da 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -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; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index c69e34e..9c5d990 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -146,6 +146,11 @@ export class UserService implements OnApplicationBootstrap { }); } + // refresh 토큰 버전 증가 — 발급된 모든 refresh 토큰 무효화(로그아웃·비밀번호 변경 시) + async incrementTokenVersion(userId: string): Promise { + await this.userRepository.increment({ id: userId }, 'tokenVersion', 1); + } + // 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거) static toPublic(user: User): PublicUser { return { diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 17e048e..40f91e4 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -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). --- diff --git a/docs/security-review.md b/docs/security-review.md new file mode 100644 index 0000000..dbdccbf --- /dev/null +++ b/docs/security-review.md @@ -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 로 최종 확인 필요.