generated from ttipo/project-base
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:
+119
-16
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user