ValidationPipe returns 500 instead of 400 for a deeply nested JSON body
Current behavior
Using ValidationPipe, a request body that is nested deeply enough returns 500 instead of 400.
POST /deep
{"a":{"a":{"a": ... }}} // 18 KB
| body size | result |
|---|---|
| 12 KB | 201 |
| 14 KB | 500 |
| 18 KB | 500 |
18 KB is well below the Express 100 KB body limit, so nothing rejects it earlier — any client can trigger this.
Removing ValidationPipe makes it work
Same app, same body, the only difference is whether ValidationPipe is applied:
POST /without-pipe 14 KB -> 201
POST /with-pipe 14 KB -> 500
Plain express.json() also returns 200 for the same body. So the body itself is fine — ValidationPipe is what turns it into a 500.
What the debugger shows
Step 1: the body is handed to class-transformer as-is
Breakpoint at packages/common/pipes/validation.pipe.ts:155:
The WATCH panel on the left shows the input at that moment:
JSON.stringify(value).length = 18001
metadata.type = 'body'
The lines above it only do toEmptyIfNil / isPrimitive / stripProtoKeys — none of them looks at nesting depth, so those 18001 bytes go straight into classTransformer.plainToInstance.
class-transformer walks the payload recursively, and ~2.500 levels exhaust the call stack:
RangeError: Maximum call stack size exceeded
at TransformOperationExecutor.transform (class-transformer/.../TransformOperationExecutor.js:299)
Step 2: the RangeError is not an HttpException, so it becomes a 500
Breakpoint at packages/core/exceptions/base-exception-filter.ts:31:
The local variable holds that exception:
exception = RangeError: Maximum call stack size exceeded
WATCH:
exception.constructor.name = 'RangeError'
exception instanceof HttpException = false
exception.getStatus = undefined
Line 31 sees it is not an HttpException and routes it to handleUnknownError() → 500.
That last step is the problem: an exception thrown by a third-party library because the input was too deeply nested escapes unmodified, and Nest never translates it into HTTP semantics.
Reproduction
@Post('deep')
deep(@Body(new ValidationPipe()) body: Dto) {
return { ok: true };
}
const depth = 3000;
const body = '{"a":'.repeat(depth) + '1' + '}'.repeat(depth); // 18001 bytes
await fetch('http://localhost:3000/deep', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
// returns 500, expected 400
Expected behavior
Input nested deeply enough to exhaust the call stack is a client input problem and should return 400, rather than letting the RangeError surface as a 500.
Related
Nest has fixed the exact same shape before:
- #17421
fix(express): map missing multer field nesting error— a multipart request nested too deeply made Multer throwLIMIT_FIELD_NESTING, and Nest returned a 500 until the error was mapped on the Nest side - #17769
fix(express): map multer errors by code instead of message
The only difference here is that class-transformer is the one throwing.
Environment
@nestjs/core 12.0.3 (also reproduces on master 8ad792aaa), class-transformer 0.5.1, express 5.2.1, Node v26.8.1
Source: nestjs/nest