generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user