fix(backend): JSON 본문 모양 한도 · UTF-8 만 — 본문 검증 DoS

본문 크기 한도 안에서도 작은 객체 수만 개면 DTO 변환 · 검사가 서버를 수 초 멈추고
수백 MB 를 썼다(리뷰 측정: 1MB 필터 본문 7.8초 · +329MB, 깊은 중첩은 500 SYS_001,
4MB 발송 본문은 한 건으로 메모리 초과 추정). DTO 의 개수 한도로는 막지 못한다 —
변환은 항목마다 객체를 만들고, 검사는 한도에 걸려도 항목마다 중첩 검사를 한다.

- 모든 JSON 본문을 DTO 보다 앞에서 잰다(배열 1,000 · 값 10,000 · 깊이 32).
  읽기 전에 UTF-8 바이트를 한 번 훑고, 읽은 뒤에 한 번 더 본다. 넘으면 400 VAL_001
- JSON 은 UTF-8 만 받는다. 다른 utf-* 본문은 바이트로 셀 수 없다
- 전역 파이프에 stopAtFirstError 를 켠다. 항목 수의 제곱이던 ArrayUnique 대신
  Set 으로 보는 ArrayDistinct 를 쓴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:42 +09:00
parent b4d30482fe
commit 7ce234750d
14 changed files with 899 additions and 32 deletions
@@ -8,7 +8,7 @@ import {
} from '@/modules/auth/auth.constants';
import { ChangePasswordDto } from './change-password.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(ChangePasswordDto, body), {
whitelist: true,
@@ -4,7 +4,7 @@ import { validate } from 'class-validator';
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
body: object,
@@ -47,7 +47,7 @@ describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-del
).toEqual(['arrayMaxSize']);
expect(
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
).toEqual(['arrayUnique']);
).toEqual(['arrayDistinct']);
for (const bad of ['../m1', '', 'm 1', 1, null]) {
expect(
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
@@ -1,13 +1,8 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
Matches,
} from 'class-validator';
import { ArrayMaxSize, ArrayMinSize, IsArray, Matches } from 'class-validator';
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
import {
MAIL_ID_PATTERN,
MAX_BULK_MESSAGE_IDS,
@@ -33,7 +28,7 @@ function MessageIdList(description: string) {
ArrayMaxSize(MAX_BULK_MESSAGE_IDS, {
message: `메일은 한 번에 ${MAX_BULK_MESSAGE_IDS}통까지 선택할 수 있습니다.`,
}),
ArrayUnique({ message: '같은 메일을 두 번 선택했습니다.' }),
ArrayDistinct({ message: '같은 메일을 두 번 선택했습니다.' }),
Matches(MAIL_ID_PATTERN, {
each: true,
message: '메일을 다시 선택해 주세요.',
@@ -9,7 +9,7 @@ import {
} from '@/modules/mail/mail.constants';
import { MailVacationDto, isUtcDateTime } from './mail-vacation.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 걸린 규칙 이름만 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
async function constraintsOf(body: object): Promise<string[]> {
const errors = await validate(plainToInstance(MailVacationDto, body), {
whitelist: true,
@@ -8,7 +8,7 @@ import {
RenameMailboxDto,
} from './mailbox.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 변환한 값과 걸린 규칙 이름 */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */
async function check<T extends object>(
type: ClassConstructor<T>,
body: object,
@@ -7,7 +7,7 @@ import {
} from '@/modules/mail/mail.constants';
import { MessageListQueryDto } from './message-list-query.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted) */
/** 전역 ValidationPipe 와 같은 옵션으로 변환·검증한다 (app.setup.ts — transform · whitelist · forbidNonWhitelisted, stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function check(
query: Record<string, unknown>,
): Promise<{ dto: MessageListQueryDto; constraints: string[] }> {
@@ -12,7 +12,7 @@ import {
} from '@/modules/mail/mail.constants';
import { SendMessageDto } from './send-message.dto';
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) */
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
async function constraintsOf(body: object): Promise<string[]> {
const dto = plainToInstance(SendMessageDto, body);
const errors = await validate(dto, {