From b9e2b0e67df551e7893aae04bc831ff27d7b3a45 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 21:46:08 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20LOW=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EB=8B=9D=20(L2~L6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DTO 길이 바운드: 업무 content/assigneeIds/checklist ArrayMaxSize·요소 MaxLength, UpdateRepoDto.description MaxLength(300) - 비밀번호 정책: 8~72자 + 영문·숫자 포함 강제, bcrypt rounds 10→12 - 그랜드페더링을 컷오프(2026-06-20) 이전 생성 계정으로 한정 - multer 업로드 오류를 400(VAL_001)로 매핑 - 운영 환경에서 CORS localhost:3000·Swagger /api-docs 노출 제외 (L1 에이전트 터미널 v-html 은 에이전트 작업 시 함께 처리 — 보류) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../common/filters/http-exception.filter.ts | 22 ++++++++++++ backend/src/main.ts | 34 ++++++++++++------- backend/src/modules/auth/auth.service.ts | 2 +- backend/src/modules/auth/dto/signup.dto.ts | 12 ++++++- .../src/modules/repo/dto/update-repo.dto.ts | 1 + .../src/modules/task/dto/create-task.dto.ts | 5 +++ .../src/modules/task/dto/update-task.dto.ts | 5 +++ backend/src/modules/user/user.service.ts | 10 ++++-- docs/integration-progress.md | 12 +++++-- docs/security-review.md | 15 +++++++- 10 files changed, 98 insertions(+), 20 deletions(-) diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index fdbec31..e51f4a8 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -22,6 +22,19 @@ export class HttpExceptionFilter implements ExceptionFilter { let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'; + // multer 업로드 오류(크기 초과 등)는 HttpException 이 아니므로 별도 400(VAL_001) 매핑 + if (this.isMulterError(exception)) { + const muMessage = + exception.code === 'LIMIT_FILE_SIZE' + ? '파일 크기가 허용 한도(25MB)를 초과했습니다.' + : '파일 업로드에 실패했습니다. 파일을 확인해 주세요.'; + response.status(HttpStatus.BAD_REQUEST).json({ + success: false, + error: { code: 'VAL_001', message: muMessage }, + }); + return; + } + if (exception instanceof HttpException) { status = exception.getStatus(); const exceptionResponse = exception.getResponse(); @@ -83,6 +96,15 @@ export class HttpExceptionFilter implements ExceptionFilter { }); } + // multer 업로드 오류 판별 (name === 'MulterError', code 보유) + private isMulterError(e: unknown): e is { name: string; code?: string } { + return ( + typeof e === 'object' && + e !== null && + (e as { name?: unknown }).name === 'MulterError' + ); + } + // 예외 응답에서 명시적 { code, message } 추출 (둘 다 문자열일 때만) private extractExplicitError( res: string | object, diff --git a/backend/src/main.ts b/backend/src/main.ts index 7d98edc..56cbe3f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -43,8 +43,15 @@ async function bootstrap() { app.use(helmet()); // 2. 화이트리스트 기반 CORS 정책 + const isProduction = process.env.NODE_ENV === 'production'; const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; const corsOriginEnv = process.env.CORS_ORIGIN; + // 운영에서는 로컬 개발 오리진(localhost:3000)을 허용 목록에서 제외 + const corsWhitelist = [ + frontendUrl, + ...(isProduction ? [] : ['http://localhost:3000']), + ...(corsOriginEnv ? [corsOriginEnv] : []), + ]; app.enableCors({ origin: ( @@ -52,10 +59,7 @@ async function bootstrap() { callback: (err: Error | null, allow?: boolean) => void, ) => { // 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용 - const whitelist = [frontendUrl, 'http://localhost:3000']; - if (corsOriginEnv) whitelist.push(corsOriginEnv); - - if (!origin || whitelist.indexOf(origin) !== -1) { + if (!origin || corsWhitelist.indexOf(origin) !== -1) { callback(null, true); } else { // 클라이언트에는 403 금지 에러로 리턴됨 @@ -94,15 +98,19 @@ async function bootstrap() { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); - // 6. Swagger API 문서 설정 - const config = new DocumentBuilder() - .setTitle('Backend API Documentation') - .setDescription('The API documentation conforming to the global guidelines') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api-docs', app, document); + // 6. Swagger API 문서 설정 — 운영 환경에서는 노출하지 않는다(공격 표면 축소) + if (!isProduction) { + const config = new DocumentBuilder() + .setTitle('Backend API Documentation') + .setDescription( + 'The API documentation conforming to the global guidelines', + ) + .setVersion('1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document); + } // 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등 // 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다. diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index e6bda46..5cccbae 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -12,7 +12,7 @@ import { LoginDto } from './dto/login.dto'; import type { JwtPayload } from './types/jwt-payload'; // bcrypt 해시 라운드 -const BCRYPT_ROUNDS = 10; +const BCRYPT_ROUNDS = 12; // 이메일 인증 토큰 유효 시간 (24시간) const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/backend/src/modules/auth/dto/signup.dto.ts b/backend/src/modules/auth/dto/signup.dto.ts index af8422b..5f42b36 100644 --- a/backend/src/modules/auth/dto/signup.dto.ts +++ b/backend/src/modules/auth/dto/signup.dto.ts @@ -3,6 +3,8 @@ import { IsNotEmpty, IsOptional, IsString, + Matches, + MaxLength, MinLength, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; @@ -14,9 +16,17 @@ export class SignupDto { @IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' }) email!: string; - @ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' }) + @ApiProperty({ + description: '비밀번호 (8~72자, 영문·숫자 포함)', + example: 'password123', + }) @IsString() @MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' }) + // bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지 + @MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' }) + @Matches(/(?=.*[A-Za-z])(?=.*\d)/, { + message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.', + }) password!: string; @ApiProperty({ description: '사용자 이름', example: '김서연' }) diff --git a/backend/src/modules/repo/dto/update-repo.dto.ts b/backend/src/modules/repo/dto/update-repo.dto.ts index fd3aa72..c43811b 100644 --- a/backend/src/modules/repo/dto/update-repo.dto.ts +++ b/backend/src/modules/repo/dto/update-repo.dto.ts @@ -13,6 +13,7 @@ export class UpdateRepoDto { @ApiProperty({ description: '프로젝트 설명', required: false }) @IsOptional() @IsString() + @MaxLength(300) description?: string; @ApiProperty({ diff --git a/backend/src/modules/task/dto/create-task.dto.ts b/backend/src/modules/task/dto/create-task.dto.ts index e5e3978..3462e25 100644 --- a/backend/src/modules/task/dto/create-task.dto.ts +++ b/backend/src/modules/task/dto/create-task.dto.ts @@ -1,4 +1,5 @@ import { + ArrayMaxSize, ArrayNotEmpty, IsArray, IsISO8601, @@ -43,7 +44,9 @@ export class CreateTaskDto { }) @IsOptional() @IsArray() + @ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' }) @IsString({ each: true }) + @MaxLength(5000, { each: true }) content?: string[]; @ApiProperty({ @@ -53,6 +56,7 @@ export class CreateTaskDto { }) @IsArray() @ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' }) + @ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' }) @IsUUID('4', { each: true }) assigneeIds!: string[]; @@ -72,6 +76,7 @@ export class CreateTaskDto { }) @IsOptional() @IsArray() + @ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' }) @ValidateNested({ each: true }) @Type(() => ChecklistInputDto) checklist?: ChecklistInputDto[]; diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts index f25beb6..f3299ba 100644 --- a/backend/src/modules/task/dto/update-task.dto.ts +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -1,4 +1,5 @@ import { + ArrayMaxSize, ArrayNotEmpty, IsArray, IsISO8601, @@ -48,7 +49,9 @@ export class UpdateTaskDto { }) @IsOptional() @IsArray() + @ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' }) @IsString({ each: true }) + @MaxLength(5000, { each: true }) content?: string[]; @ApiProperty({ @@ -59,6 +62,7 @@ export class UpdateTaskDto { @IsOptional() @IsArray() @ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' }) + @ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' }) @IsUUID('4', { each: true }) assigneeIds?: string[]; @@ -79,6 +83,7 @@ export class UpdateTaskDto { }) @IsOptional() @IsArray() + @ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' }) @ValidateNested({ each: true }) @Type(() => UpdateChecklistItemInput) checklist?: UpdateChecklistItemInput[]; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 9c5d990..1ead373 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -4,9 +4,13 @@ import { type OnApplicationBootstrap, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { ILike, IsNull, Repository } from 'typeorm'; +import { ILike, IsNull, LessThan, Repository } from 'typeorm'; import { User } from './entities/user.entity'; +// 이메일 인증 도입 컷오프 — 이 시각 이전에 생성된 로컬 계정만 그랜드페더링 대상. +// 이후 가입은 항상 정상 인증 절차를 거치므로 토큰 상태와 무관하게 자동 인증되지 않는다. +const LEGACY_VERIFY_CUTOFF = new Date('2026-06-20T00:00:00Z'); + // 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외) export interface PublicUser { id: string; @@ -27,7 +31,8 @@ export class UserService implements OnApplicationBootstrap { ) {} // 이메일 인증 도입 이전에 만들어진 로컬 계정 그랜드페더링 — - // 인증 토큰이 없는 기존 로컬 계정은 인증 완료로 간주(미인증 진행 계정은 토큰이 있어 제외). + // 컷오프 이전 생성 + 인증 토큰이 없는 로컬 계정만 인증 완료로 간주. + // 컷오프 이후 가입은 토큰 상태와 무관하게 대상에서 제외되어, 정상 인증 절차를 항상 거친다. // 멱등(idempotent): 최초 1회만 실제로 갱신되고 이후에는 대상이 없다. async onApplicationBootstrap(): Promise { const result = await this.userRepository.update( @@ -35,6 +40,7 @@ export class UserService implements OnApplicationBootstrap { provider: 'local', emailVerified: false, emailVerifyTokenHash: IsNull(), + createdAt: LessThan(LEGACY_VERIFY_CUTOFF), }, { emailVerified: true }, ); diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 40f91e4..ade847a 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -299,10 +299,18 @@ - **M3** 첨부 삭제 업로더/admin 한정. - **M4** 인증 토큰 로그 prod 게이트(원문 토큰 운영 로그 미기록). -검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요). 남은 LOW 하드닝(L1~L6: 에이전트 escape·DTO 바운드·비번정책·그랜드페더링 컷오프·multer 매핑·CORS/Swagger prod)은 차기. +검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요). + +**배치 C (LOW 하드닝, L2~L6) ✅** — L1(에이전트 v-html)은 에이전트 패널 미연동이라 사용자 요청으로 보류. +- **L2** DTO 길이 바운드(task `content`/`assigneeIds`/`checklist` `@ArrayMaxSize`+요소 `@MaxLength`, `UpdateRepoDto.description` `@MaxLength(300)`). +- **L3** 비번 정책(`@MaxLength(72)`+영문·숫자 `@Matches`) + bcrypt 10→12. +- **L4** 그랜드페더링 컷오프(`2026-06-20` 이전 생성 한정, `createdAt LessThan`). +- **L5** multer 오류 → 400(VAL_001) 매핑(전역 필터). +- **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정. +검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. #### 8-6. 그 외(미착수) -- AI 에이전트 패널 실연동, 메일 실발송(SMTP), 보안 LOW 하드닝(L1~L6). +- AI 에이전트 패널 실연동(+ L1 v-html escape), 메일 실발송(SMTP), 보안 후속(토큰 완전회전·계정잠금·비번 재설정 플로우). --- diff --git a/docs/security-review.md b/docs/security-review.md index dbdccbf..3e99b9e 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -97,4 +97,17 @@ IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignmen **검증**: 백엔드 build/lint 0 · 20 tests pass. 프론트 변경 없음(전부 서버측). -**남은 항목(C, LOW)**: L1~L6 하드닝(차기). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요. +## 적용 현황 (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·파일 콘텐츠 검증). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요.