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:
@@ -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} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
|
||||
Reference in New Issue
Block a user