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:
@@ -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,
|
||||
|
||||
+21
-13
@@ -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). 미설정 시 정리 훅이 호출되지 않는다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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), 보안 후속(토큰 완전회전·계정잠금·비번 재설정 플로우).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-1
@@ -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 로 최종 확인 필요.
|
||||
|
||||
Reference in New Issue
Block a user