From 7ce234750db3c274ccc8c88df194aab9416226d9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 20:59:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20JSON=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A8=EC=96=91=20=ED=95=9C=EB=8F=84=20=C2=B7=20UTF-8=20?= =?UTF-8?q?=EB=A7=8C=20=E2=80=94=20=EB=B3=B8=EB=AC=B8=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20DoS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 본문 크기 한도 안에서도 작은 객체 수만 개면 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 --- backend/src/app.setup.spec.ts | 269 +++++++++++++++++- backend/src/app.setup.ts | 135 +++++++-- .../array-distinct.decorator.spec.ts | 39 +++ .../decorators/array-distinct.decorator.ts | 19 ++ .../auth/dto/change-password.dto.spec.ts | 2 +- .../mail/dto/bulk-messages.dto.spec.ts | 4 +- .../src/modules/mail/dto/bulk-messages.dto.ts | 11 +- .../mail/dto/mail-vacation.dto.spec.ts | 2 +- .../src/modules/mail/dto/mailbox.dto.spec.ts | 2 +- .../mail/dto/message-list-query.dto.spec.ts | 2 +- .../modules/mail/dto/send-message.dto.spec.ts | 2 +- backend/src/shared/utils/json-shape.spec.ts | 225 +++++++++++++++ backend/src/shared/utils/json-shape.ts | 143 ++++++++++ backend/test/app.e2e-spec.ts | 76 +++++ 14 files changed, 899 insertions(+), 32 deletions(-) create mode 100644 backend/src/common/decorators/array-distinct.decorator.spec.ts create mode 100644 backend/src/common/decorators/array-distinct.decorator.ts create mode 100644 backend/src/shared/utils/json-shape.spec.ts create mode 100644 backend/src/shared/utils/json-shape.ts diff --git a/backend/src/app.setup.spec.ts b/backend/src/app.setup.spec.ts index 15e28a3..16f8f7f 100644 --- a/backend/src/app.setup.spec.ts +++ b/backend/src/app.setup.spec.ts @@ -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} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => { diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts index c51d169..c4a43f6 100644 --- a/backend/src/app.setup.ts +++ b/backend/src/app.setup.ts @@ -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()); diff --git a/backend/src/common/decorators/array-distinct.decorator.spec.ts b/backend/src/common/decorators/array-distinct.decorator.spec.ts new file mode 100644 index 0000000..3212c29 --- /dev/null +++ b/backend/src/common/decorators/array-distinct.decorator.spec.ts @@ -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 { + 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); + }); +}); diff --git a/backend/src/common/decorators/array-distinct.decorator.ts b/backend/src/common/decorators/array-distinct.decorator.ts new file mode 100644 index 0000000..cd01ff4 --- /dev/null +++ b/backend/src/common/decorators/array-distinct.decorator.ts @@ -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, + ); +} diff --git a/backend/src/modules/auth/dto/change-password.dto.spec.ts b/backend/src/modules/auth/dto/change-password.dto.spec.ts index 06d4170..9b0dac8 100644 --- a/backend/src/modules/auth/dto/change-password.dto.spec.ts +++ b/backend/src/modules/auth/dto/change-password.dto.spec.ts @@ -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 { const errors = await validate(plainToInstance(ChangePasswordDto, body), { whitelist: true, diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts index 9302542..b86cab7 100644 --- a/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts +++ b/backend/src/modules/mail/dto/bulk-messages.dto.spec.ts @@ -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 }), diff --git a/backend/src/modules/mail/dto/bulk-messages.dto.ts b/backend/src/modules/mail/dto/bulk-messages.dto.ts index cf9db0d..e460ab3 100644 --- a/backend/src/modules/mail/dto/bulk-messages.dto.ts +++ b/backend/src/modules/mail/dto/bulk-messages.dto.ts @@ -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: '메일을 다시 선택해 주세요.', diff --git a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts index 8005290..cac75f9 100644 --- a/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts +++ b/backend/src/modules/mail/dto/mail-vacation.dto.spec.ts @@ -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 { const errors = await validate(plainToInstance(MailVacationDto, body), { whitelist: true, diff --git a/backend/src/modules/mail/dto/mailbox.dto.spec.ts b/backend/src/modules/mail/dto/mailbox.dto.spec.ts index 971fb12..e1fc5ba 100644 --- a/backend/src/modules/mail/dto/mailbox.dto.spec.ts +++ b/backend/src/modules/mail/dto/mailbox.dto.spec.ts @@ -8,7 +8,7 @@ import { RenameMailboxDto, } from './mailbox.dto'; -/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts) — 변환한 값과 걸린 규칙 이름 */ +/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 변환한 값과 걸린 규칙 이름 */ async function check( type: ClassConstructor, body: object, diff --git a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts index 597bcfa..7bd7b15 100644 --- a/backend/src/modules/mail/dto/message-list-query.dto.spec.ts +++ b/backend/src/modules/mail/dto/message-list-query.dto.spec.ts @@ -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, ): Promise<{ dto: MessageListQueryDto; constraints: string[] }> { diff --git a/backend/src/modules/mail/dto/send-message.dto.spec.ts b/backend/src/modules/mail/dto/send-message.dto.spec.ts index 7f12617..d85557a 100644 --- a/backend/src/modules/mail/dto/send-message.dto.spec.ts +++ b/backend/src/modules/mail/dto/send-message.dto.spec.ts @@ -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 { const dto = plainToInstance(SendMessageDto, body); const errors = await validate(dto, { diff --git a/backend/src/shared/utils/json-shape.spec.ts b/backend/src/shared/utils/json-shape.spec.ts new file mode 100644 index 0000000..4ec47ac --- /dev/null +++ b/backend/src/shared/utils/json-shape.spec.ts @@ -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: '

본문

', + 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); + }); + }); +}); diff --git a/backend/src/shared/utils/json-shape.ts b/backend/src/shared/utils/json-shape.ts new file mode 100644 index 0000000..bb3d1c8 --- /dev/null +++ b/backend/src/shared/utils/json-shape.ts @@ -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; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 31e7d12..020b7b5 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -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')