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
+119 -16
View File
@@ -2,13 +2,21 @@ import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { json, type NextFunction, type Request, type Response } from 'express';
import helmet from 'helmet';
import type { IncomingMessage } from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import { BusinessException } from '@/common/errors/business.exception';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import {
findJsonShapeProblem,
findJsonTextShapeProblem,
type JsonShapeLimits,
type JsonShapeProblem,
} from '@/shared/utils/json-shape';
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
@@ -28,6 +36,20 @@ export const JSON_BODY_LIMIT = '1mb';
*/
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
/**
* JSON 본문의 모양 한도 — 두 본문 파서(1MB · 4MB)의 본문 모두에, 읽기 전(verifyJsonBody)과 읽은 뒤(rejectOversizedJsonBody)
* DTO 변환 · 검사 **전에** 건다 (shared/utils/json-shape.ts 머리말).
* 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단
* (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개).
* 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다.
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.5초 안에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다)
*/
export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
};
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
@@ -61,6 +83,83 @@ export function isDraftSaveRequest(
return id !== '' && !id.includes('/');
}
/** 모양 한도를 넘은 본문의 실패 — 400 VAL_001. 로그에는 넘은 한도의 이름과 본 자리(읽기 전 · 뒤)만 남는다 */
function jsonShapeFailure(
problem: JsonShapeProblem,
stage: 'before-parse' | 'after-parse',
): BusinessException {
return new BusinessException('VAL_001', {
context: { reason: 'json-shape-limit', problem, stage },
});
}
/** 글자 인코딩 이름의 비교용 모양 — body-parser 가 본문을 글자로 바꿀 때(iconv-lite)와 같게 소문자 영숫자만 남긴다 */
function canonicalCharset(label: string): string {
return label.toLowerCase().replace(/:\d{4}$|[^0-9a-z]/g, '');
}
/** UTF-8 의 비교용 이름 — `utf-8` · `UTF-8` · `utf-_8` 이 모두 이것이다 (body-parser 는 이 본문을 모두 UTF-8 로 읽는다) */
const UTF8_CHARSET = 'utf8';
/**
* 본문 파서의 읽기 전 확인(body-parser 의 verify) — UTF-8 본문을 JSON.parse 하기 **전에** 한 번 훑어 모양 한도를 넘으면 멈춘다.
* JSON.parse 도 작은 객체가 아주 많으면 무겁다 (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초 — 2026-10-28 측정).
*
* JSON 본문은 UTF-8 만 받는다 (RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다). body-parser 는 `utf-` 로 시작하는 charset 을
* 모두 받지만(utf-16 · utf-32 · utf-7) 그 본문은 바이트로 셀 수 없어 JSON.parse 전에 끊지 못한다 — 그래서 400 VAL_001 로 거절한다.
* 이름은 견줄 모양으로 바꿔 본다: 글자 그대로 `utf-8` 과 견주면 `utf-_8` 처럼 UTF-8 로 읽히는 다른 이름이 확인을 건너뛴다.
*
* 여기서 던진 오류는 body-parser 가 http-errors 로 감싸며 상태를 오류에서 가져와 400 이 되고(BusinessException 의 status),
* 전역 필터가 VAL_001 로 답한다 (app.e2e-spec.ts 가 실제 응답으로 본다). 본문 파서는 가드보다 앞이라 로그인 전의 요청도 같다
*/
export function verifyJsonBody(
_req: IncomingMessage,
_res: ServerResponse,
buffer: Buffer,
encoding: string,
): void {
if (canonicalCharset(encoding) !== UTF8_CHARSET) {
throw new BusinessException('VAL_001', {
context: { reason: 'json-not-utf8' },
});
}
const problem = findJsonTextShapeProblem(buffer, JSON_SHAPE_LIMITS);
if (problem !== null) throw jsonShapeFailure(problem, 'before-parse');
}
/**
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
*/
export function rejectOversizedJsonBody(
req: Request,
_res: Response,
next: NextFunction,
): void {
const body: unknown = req.body;
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
if (problem === null) {
next();
return;
}
next(jsonShapeFailure(problem, 'after-parse'));
}
/**
* 전역 DTO 검증 — 첫 실패에서 그 칸의 남은 검사를 멈춘다(stopAtFirstError).
* 배열 길이 한도에 걸린 칸은 항목마다의 중첩 검사를 하지 않는다 — 실패 응답은 늘 VAL_001 이라 어느 제약이 먼저 걸렸는지는 쓰지 않는다
* (도메인 코드 — 받는 사람 합계 MAIL_002 · 첨부 수 MAIL_006 · 본문 길이 MAIL_007 등 — 는 검증을 지난 뒤 서비스가 고른다)
*/
export function createValidationPipe(): ValidationPipe {
return new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
stopAtFirstError: true,
});
}
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
function isJsonBody(req: IncomingMessage): boolean {
const mediaType = (req.headers['content-type'] ?? '')
@@ -103,23 +202,27 @@ export function configureApp(app: NestExpressApplication): void {
//
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
app.useBodyParser('json', {
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
});
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
// 전역 DTO 검증
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // DTO 에 없는 값은 버린다
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
transform: true, // DTO·기본 타입으로 변환
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
app.use(
json({
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
inflate: false,
type: (req: IncomingMessage) =>
(isSendMessageRequest(req) || isDraftSaveRequest(req)) &&
isJsonBody(req),
verify: verifyJsonBody,
}),
);
app.use(
json({ limit: JSON_BODY_LIMIT, inflate: false, verify: verifyJsonBody }),
);
// 두 파서가 읽은 본문 모두 — 배열 길이 · 값 수 · 깊이가 한도를 넘으면 DTO 변환 · 검사 전에 400 (JSON_SHAPE_LIMITS)
app.use(rejectOversizedJsonBody);
// 전역 DTO 검증
app.useGlobalPipes(createValidationPipe());
// 표준 실패·성공 응답
app.useGlobalFilters(new HttpExceptionFilter());