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:
2026-10-28 20:59:42 +09:00
parent b4d30482fe
commit 7ce234750d
14 changed files with 899 additions and 32 deletions
+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')