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:
@@ -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