fix: 보안 LOW 하드닝 (L2~L6)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 21:46:08 +09:00
parent 8efcacb6bb
commit b9e2b0e67d
10 changed files with 98 additions and 20 deletions
+1 -1
View File
@@ -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;
+11 -1
View File
@@ -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: '김서연' })
@@ -13,6 +13,7 @@ export class UpdateRepoDto {
@ApiProperty({ description: '프로젝트 설명', required: false })
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
@ApiProperty({
@@ -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[];
@@ -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[];
+8 -2
View File
@@ -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<void> {
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 },
);