chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
14 changed files with 899 additions and 32 deletions
Showing only changes of commit 7ce234750d - Show all commits
+268 -1
View File
@@ -1,4 +1,271 @@
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
import { BadRequestException, HttpStatus } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
import { MessageDeletionDto } from '@/modules/mail/dto/bulk-messages.dto';
import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
import {
JSON_SHAPE_LIMITS,
createValidationPipe,
isDraftSaveRequest,
isSendMessageRequest,
rejectOversizedJsonBody,
verifyJsonBody,
} from './app.setup';
/** 본문 파서의 읽기 전 확인을 부른다 — 던진 것을 돌려준다 (던지지 않으면 undefined) */
function verifyFailure(text: string, encoding = 'utf-8'): unknown {
try {
verifyJsonBody(
{} as IncomingMessage,
{} as ServerResponse,
Buffer.from(text, 'utf8'),
encoding,
);
} catch (error) {
return error;
}
return undefined;
}
/** 읽은 뒤의 확인을 부른다 — next 에 넘긴 것 */
function afterParse(body: unknown): unknown[] {
const next = jest.fn();
rejectOversizedJsonBody({ body } as Request, {} as Response, next);
return next.mock.calls[0] as unknown[];
}
const repeat = (unit: string, count: number) =>
Array.from({ length: count }, () => unit).join(',');
describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검사 전)', () => {
it('읽기 전 확인 — 넘으면 BusinessException VAL_001(상태 400 — body-parser 가 이 상태를 쓴다), 까닭은 로그용 context 에만', () => {
const failure = verifyFailure(`{"ids":[${repeat('1', 1_001)}]}`);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).getStatus()).toBe(
HttpStatus.BAD_REQUEST,
);
// http-errors 는 감싼 오류의 status 를 상태 코드로 쓴다 (body-parser 의 createError(403, err))
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
expect(verifyFailure(`{"ids":[${repeat('1', 1_000)}]}`)).toBeUndefined();
});
it('JSON 본문은 UTF-8 만 — 다른 글자 인코딩(바이트로 셀 수 없다)은 작은 본문도 400 VAL_001', () => {
// body-parser 는 `utf-` 로 시작하는 charset 을 모두 받아 소문자로 넘긴다
for (const encoding of [
'utf-16',
'utf-16le',
'utf-16be',
'utf-32',
'utf-7',
]) {
const failure = verifyFailure('{"ids":[]}', encoding);
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as { status?: unknown }).status).toBe(400);
expect((failure as BusinessException).code).toBe('VAL_001');
expect((failure as BusinessException).context).toEqual({
reason: 'json-not-utf8',
});
}
});
it('UTF-8 로 읽히는 다른 이름(utf-_8 등)도 UTF-8 로 보고 모양을 본다 — 이름 글자 그대로 견주면 확인을 건너뛴다', () => {
for (const encoding of [
'utf-8',
'utf-_8',
'utf--8',
'utf-8.',
'utf-8:2026',
]) {
expect(verifyFailure('{"ids":[]}', encoding)).toBeUndefined();
expect(
(
verifyFailure(
`{"ids":[${repeat('1', 1_001)}]}`,
encoding,
) as BusinessException
).context,
).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'before-parse',
});
}
});
it('읽은 뒤 확인 — 한도 안이면 그대로 넘기고(next()), 본문이 없는 요청도 지난다. 넘으면 next(VAL_001)', () => {
expect(afterParse({ rules: [], version: 'v' })).toEqual([]);
expect(afterParse(undefined)).toEqual([]);
const [failure] = afterParse({
ids: Array.from({ length: 5_000 }, () => 1),
});
expect(failure).toBeInstanceOf(BusinessException);
expect((failure as BusinessException).context).toEqual({
reason: 'json-shape-limit',
problem: 'array-too-long',
stage: 'after-parse',
});
});
it('한도는 계약의 어떤 본문보다 넉넉하다 — 배열 1,000 · 값 10,000 · 깊이 32', () => {
expect(JSON_SHAPE_LIMITS).toEqual({
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
});
});
describe('리뷰에서 잰 모양이 400 으로 0.5초 안에 끝난다 (본문 파서의 읽기 전 확인 — 본문은 재기 전에 만든다)', () => {
it.each([
[
'필터 rules 에 {"id":N} 8만 개 (1MB)',
`{"version":"v","rules":[${Array.from(
{ length: 80_000 },
(_, index) => `{"id":${index}}`,
).join(',')}]}`,
],
[
'규칙 하나의 conditions 에 {} 15만 개',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
],
[
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
],
[
'발송 to 에 {} 139만 개 (4MB 경로의 최대)',
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
],
[
'여러 통 ids 에 문자열 6만 개',
`{"ids":[${Array.from(
{ length: 60_000 },
(_, index) => `"${index.toString(36)}"`,
).join(',')}]}`,
],
[
'로그인 본문에 10만 단 중첩 (600KB — 전에는 DTO 변환의 재귀가 넘쳐 500)',
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
],
])('%s', (_label, text) => {
const cpuMs = startCpuTimer();
const failure = verifyFailure(text);
expect(cpuMs()).toBeLessThan(500);
expect((failure as BusinessException).code).toBe('VAL_001');
});
});
});
describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => {
/** 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 0.5초 안에 400 이다 */
const filtersWithManyUnknowns = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
id: `r${index}`,
name: '',
isEnabled: true,
match: 'all',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject',
operator: 'contains',
value: 'x',
extra: Array.from({ length: 32 }, () => 1),
})),
actions: {
moveToMailboxId: null,
markAsRead: true,
flag: false,
forwardTo: null,
},
})),
};
it.each<[string, new () => object, unknown]>([
[
'필터 rules 1,000개 (배열 한도까지)',
MailFilterUpdateDto,
{
version: 'v',
rules: Array.from({ length: 1_000 }, (_, index) => ({ id: index })),
},
],
[
'규칙 하나의 conditions 1,000개',
MailFilterUpdateDto,
{
version: 'v',
rules: [
{ id: 'r', conditions: Array.from({ length: 1_000 }, () => ({})) },
],
},
],
[
'필터 50개 × 조건 5개마다 모르는 배열 — 값 약 9,800개 (값 한도 가까이)',
MailFilterUpdateDto,
filtersWithManyUnknowns,
],
[
'발송 to 1,000명',
SendMessageDto,
{
subject: 'x',
body: 'x',
to: Array.from({ length: 1_000 }, () => ({})),
},
],
[
'여러 통 ids 1,000개',
MessageDeletionDto,
{ ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) },
],
])(
'%s — 모양 확인을 지나도 DTO 검사가 0.5초 안에 400',
async (_label, metatype, body) => {
const text = JSON.stringify(body);
const pipe = createValidationPipe();
const cpuMs = startCpuTimer();
expect(verifyFailure(text)).toBeUndefined();
const parsed: unknown = JSON.parse(text);
expect(afterParse(parsed)).toEqual([]);
await expect(
pipe.transform(parsed, { type: 'body', metatype }),
).rejects.toBeInstanceOf(BadRequestException);
expect(cpuMs()).toBeLessThan(500);
},
);
it('검사 결과는 그대로다 — 맞는 본문은 지나고, 한도를 넘거나 모르는 칸이 있으면 400', async () => {
const pipe = createValidationPipe();
const valid = {
rules: [],
version: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
};
await expect(
pipe.transform(valid, { type: 'body', metatype: MailFilterUpdateDto }),
).resolves.toBeInstanceOf(MailFilterUpdateDto);
for (const body of [
{ ...valid, isActive: true },
{ ...valid, rules: Array.from({ length: 51 }, () => ({})) },
{ ...valid, version: 'a b' },
]) {
await expect(
pipe.transform(body, { type: 'body', metatype: MailFilterUpdateDto }),
).rejects.toBeInstanceOf(BadRequestException);
}
});
});
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
+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());
@@ -0,0 +1,39 @@
import { validate } from 'class-validator';
import { startCpuTimer } from '@/../test/cpu-time';
import { ArrayDistinct } from './array-distinct.decorator';
class Ids {
@ArrayDistinct({ message: '같은 값이 두 번 들어 있습니다.' })
ids!: unknown;
}
async function constraintsOf(ids: unknown): Promise<string[]> {
const target = Object.assign(new Ids(), { ids });
const errors = await validate(target);
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
}
describe('ArrayDistinct — 배열 항목이 겹치지 않는다 (선형)', () => {
it('겹치는 값이 있으면 arrayDistinct — 원시 값은 값으로 견준다', async () => {
expect(await constraintsOf(['m1', 'm2'])).toEqual([]);
expect(await constraintsOf([])).toEqual([]);
expect(await constraintsOf(['m1', 'm2', 'm1'])).toEqual(['arrayDistinct']);
expect(await constraintsOf([1, 1])).toEqual(['arrayDistinct']);
// 문자열 '1' 과 숫자 1 은 다른 값이다 (ArrayUnique 와 같다)
expect(await constraintsOf(['1', 1])).toEqual([]);
});
it('배열이 아니면 실패한다', async () => {
for (const ids of ['m1', null, undefined, { 0: 'm1' }]) {
expect(await constraintsOf(ids)).toEqual(['arrayDistinct']);
}
});
it('항목 수에 비례한다 — 20만 개도 0.5초 안 (ArrayUnique 는 6만 개에 4.7초였다)', async () => {
const ids = Array.from({ length: 200_000 }, (_, index) => `m${index}`);
const cpuMs = startCpuTimer();
expect(await constraintsOf(ids)).toEqual([]);
expect(cpuMs()).toBeLessThan(500);
});
});
@@ -0,0 +1,19 @@
import { ValidateBy, type ValidationOptions } from 'class-validator';
/**
* 배열 항목이 겹치지 않는다 — Set 으로 한 번 훑는다(항목 수에 비례). 배열이 아니면 실패한다(IsArray 가 함께 알린다).
* class-validator 의 ArrayUnique 는 항목마다 indexOf 로 앞을 다시 훑어 항목 수의 제곱이다 — id 6만 개에 4.7초 (2026-10-28 리뷰).
* 견주는 방법은 ArrayUnique 와 같다(같은 값 — 원시 값은 값으로, 객체는 같은 객체일 때만)
*/
export function ArrayDistinct(options: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'arrayDistinct',
validator: {
validate: (value: unknown) =>
Array.isArray(value) && new Set(value).size === value.length,
},
},
options,
);
}
@@ -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, {
+225
View File
@@ -0,0 +1,225 @@
import { startCpuTimer } from '@/../test/cpu-time';
import {
findJsonShapeProblem,
findJsonTextShapeProblem,
type JsonShapeLimits,
} from './json-shape';
const LIMITS: JsonShapeLimits = {
maxArrayLength: 1_000,
maxValues: 10_000,
maxDepth: 32,
};
/** 읽기 전(UTF-8 글자)과 읽은 뒤(값)의 판정 — 같은 본문이면 같아야 한다 */
const textProblem = (text: string) =>
findJsonTextShapeProblem(Buffer.from(text, 'utf8'), LIMITS);
const bothProblems = (value: unknown) => ({
parsed: findJsonShapeProblem(value, LIMITS),
text: textProblem(JSON.stringify(value)),
pretty: textProblem(JSON.stringify(value, null, 2)),
});
const numbers = (count: number) =>
Array.from({ length: count }, (_, index) => index);
const nested = (depth: number): unknown =>
depth === 0 ? 1 : { a: nested(depth - 1) };
/** 계약에서 가장 큰 본문들 — 필터 50개를 모두 채운 저장 · 받는 사람과 첨부 목록을 모두 채운 발송 */
const fullFilters = {
version: 'v',
rules: Array.from({ length: 50 }, (_, index) => ({
id: `r${index}`,
name: '가'.repeat(50),
isEnabled: true,
match: 'any',
conditions: Array.from({ length: 5 }, () => ({
field: 'subject',
operator: 'contains',
value: '나'.repeat(200),
})),
actions: {
moveToMailboxId: 'j',
markAsRead: true,
flag: true,
forwardTo: 'doyun.lee@arasystem.test',
},
})),
};
const recipients = (count: number) =>
Array.from({ length: count }, (_, index) => ({
name: '이도윤',
email: `u${index}@arasystem.test`,
}));
const fullSend = {
to: recipients(100),
cc: recipients(100),
bcc: recipients(100),
subject: '제목',
body: '본문',
bodyHtml: '<p>본문</p>',
attachments: Array.from({ length: 100 }, (_, index) => ({
id: `a${index}`,
name: `파일${index}.pdf`,
})),
inReplyTo: 'm1',
draftId: 'd1',
};
describe('JSON 본문의 모양 한도 — 읽은 뒤(findJsonShapeProblem) · 읽기 전(findJsonTextShapeProblem)', () => {
it('계약에서 가장 큰 본문들(필터 50개 · 받는 사람 300명과 첨부 100개)은 한도 안이다', () => {
for (const body of [fullFilters, fullSend, { ids: numbers(100) }]) {
expect(bothProblems(body)).toEqual({
parsed: null,
text: null,
pretty: null,
});
}
});
it('원시 값 · 빈 본문 · 빈 객체와 배열은 한도 안이다', () => {
for (const body of [null, 1, 'x', true, {}, [], { a: [] }, [[], {}]]) {
expect(bothProblems(body)).toEqual({
parsed: null,
text: null,
pretty: null,
});
}
expect(findJsonShapeProblem(undefined, LIMITS)).toBeNull();
expect(textProblem('')).toBeNull();
});
it('배열 하나는 1,000개까지 — 1,001개면 array-too-long (중첩된 배열도)', () => {
expect(bothProblems({ ids: numbers(1_000) }).parsed).toBeNull();
for (const body of [
{ ids: numbers(1_001) },
{ rules: [{ conditions: numbers(1_001).map(() => ({})) }] },
[numbers(1_001)],
]) {
expect(bothProblems(body)).toEqual({
parsed: 'array-too-long',
text: 'array-too-long',
pretty: 'array-too-long',
});
}
});
it('값은 본문 전체에서 10,000개까지 — 객체 · 배열 · 원시 값을 하나씩 센다', () => {
// 맨 바깥 1 + 키 9개 + 배열 9 × 1,000 = 9,010
const within = Object.fromEntries(
Array.from({ length: 9 }, (_, index) => [`k${index}`, numbers(1_000)]),
);
expect(bothProblems(within).parsed).toBeNull();
// 1 + 10 + 10 × 1,000 = 10,011
const over = Object.fromEntries(
Array.from({ length: 10 }, (_, index) => [`k${index}`, numbers(1_000)]),
);
expect(bothProblems(over)).toEqual({
parsed: 'too-many-values',
text: 'too-many-values',
pretty: 'too-many-values',
});
// 키가 아주 많은 객체 하나도 같다
const wide = Object.fromEntries(
numbers(10_000).map((index) => [`k${index}`, index]),
);
expect(bothProblems(wide).parsed).toBe('too-many-values');
});
it('객체 · 배열은 32단까지 — 33단이면 too-deep', () => {
expect(bothProblems(nested(32)).parsed).toBeNull();
expect(bothProblems(nested(33))).toEqual({
parsed: 'too-deep',
text: 'too-deep',
pretty: 'too-deep',
});
});
it('읽기 전 셈은 문자열 안의 구조 글자 · 이스케이프 · 여러 바이트 글자를 세지 않는다', () => {
const tricky = {
a: '[{,,,}] "따옴표" \\ \\" ,[',
b: ['\u{1f334}', '가,나', '\\', '"', ''],
c: { '키,[': '값]}', '': { '"': ',' } },
};
expect(textProblem(JSON.stringify(tricky))).toBe(
findJsonShapeProblem(tricky, LIMITS),
);
// 쉼표 · 괄호가 아주 많은 문자열도 값 하나다
expect(textProblem(JSON.stringify({ s: ',['.repeat(500_000) }))).toBeNull();
});
it('틀린 JSON 에서도 던지거나 멈추지 않는다 — 판정은 뒤의 JSON.parse 가 한다', () => {
for (const text of [
'{',
'}',
']]]]',
'{"a":',
'"끝나지 않은',
'[1,,2]',
'\\',
'{"a":"\\',
]) {
expect(() => textProblem(text)).not.toThrow();
}
});
describe('리뷰에서 잰 모양 — 넘는 곳에서 바로 멈춘다 (CPU 시간, 본문은 재기 전에 만든다)', () => {
const repeat = (unit: string, count: number) =>
Array.from({ length: count }, () => unit).join(',');
const shapes: [string, string][] = [
[
'필터 rules 에 {"id":N} 8만 개 (1MB)',
`{"version":"v","rules":[${numbers(80_000)
.map((index) => `{"id":${index}}`)
.join(',')}]}`,
],
[
'규칙 하나의 conditions 에 {} 15만 개 (450KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
],
[
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
],
[
'발송 to 에 {} 139만 개 (4MB — 발송 · 임시 저장 경로의 최대)',
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
],
[
'여러 통 ids 에 문자열 6만 개',
`{"ids":[${numbers(60_000)
.map((index) => `"${index.toString(36)}"`)
.join(',')}]}`,
],
];
it.each(shapes)(
'%s — 읽기 전에 array-too-long, 0.5초 안',
(_label, text) => {
const bytes = Buffer.from(text, 'utf8');
const cpuMs = startCpuTimer();
expect(findJsonTextShapeProblem(bytes, LIMITS)).toBe('array-too-long');
expect(cpuMs()).toBeLessThan(500);
},
);
it.each(shapes.filter(([, text]) => text.length < 1_100_000))(
'%s — 읽은 뒤에도(JSON.parse 포함) array-too-long, 0.5초 안',
(_label, text) => {
const cpuMs = startCpuTimer();
expect(findJsonShapeProblem(JSON.parse(text), LIMITS)).toBe(
'array-too-long',
);
expect(cpuMs()).toBeLessThan(500);
},
);
it('10만 단 중첩(600KB)도 재귀 없이 바로 too-deep — 읽기 전 · 읽은 뒤 모두', () => {
const text = `{"z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`;
const cpuMs = startCpuTimer();
expect(textProblem(text)).toBe('too-deep');
expect(findJsonShapeProblem(JSON.parse(text), LIMITS)).toBe('too-deep');
expect(cpuMs()).toBeLessThan(500);
});
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* JSON 본문의 모양 한도 (순수 함수) — 본문 파서 다음, DTO 변환 · 검사(ValidationPipe) **전에** 본다 (app.setup.ts).
*
* 왜: DTO 변환(class-transformer)은 배열 항목마다 DTO 객체를 만들고, 검사(class-validator)는 배열 길이 한도에 걸려도 항목마다 중첩 검사를 한다.
* 본문 크기 한도 안에서도 작은 객체 수십만 개면 몇 초씩 CPU 를 붙잡고 수백 MB 를 쓰며, 오류가 너무 많으면 스택이 넘쳐 500 이 된다
* (2026-10-28 리뷰: 1MB 에 규칙 8만 개 7.8초 · +329MB, 조건 15만 개 6.3초 → 500 SYS_001, 4MB 발송 경로는 한 건으로 메모리가 넘친다).
* 깊게 중첩한 객체도 DTO 변환의 재귀가 스택을 넘겨 500 이 된다 (60KB 로 1만 단).
*
* 그래서 값 수 · 배열 길이 · 깊이를 세다가 넘는 순간 멈춘다. 두 번 본다:
* - 읽기 전(findJsonTextShapeProblem) — UTF-8 글자 그대로 한 번 훑는다. JSON.parse 자체도 작은 객체가 많으면 무겁다
* (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초) — 그 전에 끊는다
* - 읽은 뒤(findJsonShapeProblem) — 값으로 위에서부터. 글자 인코딩과 상관없는 마지막 확인이다 (UTF-16 본문도 받는다)
* 둘 다 재귀 없이 돌아 깊이와 상관없고, 넘는 순간 멈춘다.
*/
export interface JsonShapeLimits {
/** 배열 하나의 항목 수 */
readonly maxArrayLength: number;
/** 본문 전체의 값 수 — 객체 · 배열 · 원시 값을 하나씩 센다 (맨 바깥 포함) */
readonly maxValues: number;
/** 객체 · 배열이 겹친 깊이 — 맨 바깥 객체가 1 */
readonly maxDepth: number;
}
/** 넘은 한도 — 로그용 */
export type JsonShapeProblem =
| 'array-too-long'
| 'too-many-values'
| 'too-deep';
interface PendingContainer {
readonly value: object;
readonly depth: number;
}
/** JSON 의 구조 글자 (UTF-8 의 한 바이트 — 여러 바이트 글자의 어느 바이트와도 겹치지 않는다) */
const QUOTE = 0x22;
const BACKSLASH = 0x5c;
const COMMA = 0x2c;
const OPEN_BRACE = 0x7b;
const CLOSE_BRACE = 0x7d;
const OPEN_BRACKET = 0x5b;
const CLOSE_BRACKET = 0x5d;
const SPACE = 0x20;
const TAB = 0x09;
const LF = 0x0a;
const CR = 0x0d;
/** JSON 의 빈칸인가 — 공백 · 탭 · LF · CR (한 바이트씩 보는 자리라 비교로 둔다) */
function isJsonWhitespace(byte: number): boolean {
return byte === SPACE || byte === TAB || byte === LF || byte === CR;
}
/**
* 읽기 전의 본문(UTF-8 바이트)이 모양 한도를 넘는지 — findJsonShapeProblem 과 같은 한도 · 같은 셈(값 = 맨 바깥 + 객체 · 배열마다 그 항목 수).
* 따옴표 문자열 안(이스케이프 포함)은 건너뛰고, 열린 객체 · 배열마다 항목을 센다 — 첫 항목은 그 첫 글자에서, 다음 항목은 쉼표에서.
* JSON 이 맞는지는 보지 않는다 — 틀린 글이면 뒤의 JSON.parse 가 거절한다(여기서는 셈만 어긋날 뿐 멈추거나 던지지 않는다)
*/
export function findJsonTextShapeProblem(
text: Uint8Array,
limits: JsonShapeLimits,
): JsonShapeProblem | null {
/** 열린 객체 · 배열 — 배열인가 · 지금까지 센 항목 수 */
const isArray: boolean[] = [];
const items: number[] = [];
let values = 1;
let inString = false;
for (let index = 0; index < text.length; index += 1) {
const byte = text[index];
if (inString) {
if (byte === BACKSLASH) index += 1;
else if (byte === QUOTE) inString = false;
continue;
}
if (isJsonWhitespace(byte)) continue;
const top = items.length - 1;
if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) {
isArray.pop();
items.pop();
continue;
}
if (byte === COMMA) {
if (top < 0) continue;
items[top] += 1;
values += 1;
if (values > limits.maxValues) return 'too-many-values';
if (isArray[top] && items[top] > limits.maxArrayLength) {
return 'array-too-long';
}
continue;
}
// 그 밖의 글자 — 열린 객체 · 배열의 첫 내용이면 첫 항목이다 (객체는 키의 따옴표, 배열은 값의 첫 글자)
if (top >= 0 && items[top] === 0) {
items[top] = 1;
values += 1;
if (values > limits.maxValues) return 'too-many-values';
}
if (byte === QUOTE) {
inString = true;
} else if (byte === OPEN_BRACE || byte === OPEN_BRACKET) {
isArray.push(byte === OPEN_BRACKET);
items.push(0);
if (items.length > limits.maxDepth) return 'too-deep';
}
}
return null;
}
/**
* 본문이 모양 한도를 넘는지 — 넘으면 그 까닭, 아니면 null (원시 값 · 빈 본문은 null).
* 배열은 길이부터 본다(항목을 열기 전에), 값 수는 객체 · 배열을 열 때마다 그 항목 수를 더해 본다.
*/
export function findJsonShapeProblem(
body: unknown,
limits: JsonShapeLimits,
): JsonShapeProblem | null {
if (typeof body !== 'object' || body === null) return null;
const pending: PendingContainer[] = [{ value: body, depth: 1 }];
let values = 1;
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (next.depth > limits.maxDepth) return 'too-deep';
let children: unknown[];
if (Array.isArray(next.value)) {
if (next.value.length > limits.maxArrayLength) return 'array-too-long';
children = next.value;
} else {
children = Object.values(next.value);
}
values += children.length;
if (values > limits.maxValues) return 'too-many-values';
for (const child of children) {
if (typeof child === 'object' && child !== null) {
pending.push({ value: child, depth: next.depth + 1 });
}
}
}
return null;
}
+76
View File
@@ -935,6 +935,82 @@ describe('응답 계약 (e2e)', () => {
}
});
/**
* JSON 본문의 모양 한도(app.setup.ts 의 JSON_SHAPE_LIMITS — 배열 1,000 · 값 10,000 · 깊이 32)와 UTF-8 만.
* 본문 파서 단계라 인증 · 요청 수 한도보다 앞이다 — 로그인 전의 요청도 JSON.parse · DTO 변환 · 검사 전에 끊는다.
* CPU 시간은 app.setup.spec.ts · json-shape.spec.ts 가 잰다 — 여기서는 실제 HTTP 응답(상태 · 코드 · traceId)을 본다
*/
describe('JSON 본문의 모양 한도 · UTF-8 만 (본문 파서 단계)', () => {
const expectRejectedBody = (res: Response) => {
expect(res.status).toBe(400);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('VAL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
};
const items = (count: number, item: (index: number) => string) =>
Array.from({ length: count }, (_, index) => item(index)).join(',');
it('필터 저장(1MB 경로)에 규칙 8만 개 — 로그인한 직원도 DTO 검사 전에 400 VAL_001, 엔진에 묻지 않는다', async () => {
const before = filterSaves.length;
const res = await request(server)
.put('/api/mail-filters')
.set('Cookie', cookieOf('shape.me@e2e.arasystem.test'))
.set('Content-Type', 'application/json')
.send(
`{"version":"v","rules":[${items(80_000, (index) => `{"id":${index}}`)}]}`,
);
expectRejectedBody(res);
expect(filterSaves).toHaveLength(before);
});
it('발송(4MB 경로)의 to 에 {} 139만 개 — 로그인 전이어도 401 이 아니라 400 VAL_001 (본문을 읽기 전에 끊는다)', async () => {
const text = `{"subject":"x","body":"x","to":[${items(1_390_000, () => '{}')}]}`;
expect(text.length).toBeGreaterThan(4_000_000);
expectRejectedBody(
await request(server)
.post('/api/messages')
.set('Content-Type', 'application/json')
.send(text),
);
});
it('10만 단 중첩한 본문은 400 VAL_001 — 전에는 DTO 변환의 재귀가 넘쳐 500 SYS_001 이었다', async () => {
expectRejectedBody(
await request(server)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send(
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
),
);
});
it('UTF-8 이 아닌 JSON 본문(charset=utf-16le · utf-7)은 작아도 400 VAL_001 — utf-_8 처럼 UTF-8 로 읽히는 이름은 받는다', async () => {
const payload = JSON.stringify({ ids: ['eaaaaab'], isRead: true });
for (const [charset, bytes] of [
['utf-16le', Buffer.from(payload, 'utf16le')],
['utf-7', Buffer.from(payload, 'latin1')],
] as const) {
expectRejectedBody(
await request(server)
.patch('/api/messages')
.set('Content-Type', `application/json; charset=${charset}`)
.send(bytes),
);
}
// 본문 파서를 지나 인증 검사에 닿는다 — 로그인하지 않아 401
const utf8 = await request(server)
.patch('/api/messages')
.set('Content-Type', 'application/json; charset=utf-_8')
.send(payload)
.expect(401);
expect(body(utf8).error?.code).toBe('AUTH_001');
});
});
it('형식이 맞는 x-request-id 는 이어받고, 그 밖의 값은 새로 발급한다', async () => {
const kept = await request(server)
.get('/api/users/999999')