fix(fastify): send the status of an exception whose body is an error

FastifyAdapter.reply() handed an Error body, as in
new UnauthorizedException(err), to Fastify, which passes an Error
payload to the error handler instead of sending it. Routes then answered
500 rather than the status of the exception, and on the raw response
that middleware gets, which has no error handler, the send threw.

An Error body sent with an error status now gets the standard body of
that status instead, e.g. {"statusCode":401,"message":"Unauthorized"},
from routes and middleware alike. The properties of the Error (such as
the request config of an HTTP client error) are not sent, whereas the
ExpressAdapter serializes them.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-3CVCDVPD.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-19 10:22:20 +02:00
1 parent 6e345edc1b
commit 614d1ac7ea
4 files changed
+90 -4

No files matched your search

+32 -2
View File
@@ -1,4 +1,11 @@
import { Controller, HttpStatus, INestApplication, Post } from '@nestjs/common';
import {
Controller,
Get,
HttpStatus,
INestApplication,
Post,
UnauthorizedException,
} from '@nestjs/common';
import {
FastifyAdapter,
NestFastifyApplication,
@@ -10,6 +17,14 @@ import request from 'supertest';
import { ErrorsController } from '../src/errors/errors.controller.js';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
@Controller()
class ErrorBodyController {
@Get('error-body')
findAll() {
throw new UnauthorizedException(new Error('invalid signature'));
}
}
@Controller()
class SchemaController {
@RouteSchema({ body: { type: 'object', required: ['name'] } })
@@ -83,7 +98,7 @@ describe('Error messages', () => {
let app: NestFastifyApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [ErrorsController, SchemaController],
controllers: [ErrorsController, SchemaController, ErrorBodyController],
}).compile();
app = module.createNestApplication<NestFastifyApplication>(
@@ -155,6 +170,21 @@ describe('Error messages', () => {
});
});
it(`/GET (Unauthorized when the body of the HttpException is an Error)`, async () => {
return app
.inject({
method: 'GET',
url: '/error-body',
})
.then(({ payload, statusCode }) => {
expect(statusCode).toBe(HttpStatus.UNAUTHORIZED);
expect(JSON.parse(payload)).toEqual({
statusCode: 401,
message: 'Unauthorized',
});
});
});
it(`/POST (BadRequest when the route schema rejects the body)`, async () => {
return app
.inject({
@@ -1133,6 +1133,9 @@ describe('Middleware (FastifyAdapter)', () => {
if (req.headers['x-throw'] === 'error-body') {
throw new UnauthorizedException(new Error('invalid signature'));
}
if (req.headers['x-throw'] === 'unserializable-body') {
throw new BadRequestException({ n: 10n });
}
next();
}
}
@@ -1186,12 +1189,22 @@ describe('Middleware (FastifyAdapter)', () => {
});
});
// The exception layer fails to send an "Error" body on the raw response
it('should send the status of an HttpException whose body is an Error', () => {
return request(app.getHttpServer())
.get('/protected')
.set('x-throw', 'error-body')
.expect(401, {
statusCode: 401,
message: 'Unauthorized',
});
});
// The exception layer fails to serialize the body on the raw response
// middleware receives
it('should send the internal server error response when the exception layer fails', async () => {
await request(app.getHttpServer())
.get('/protected')
.set('x-throw', 'error-body')
.set('x-throw', 'unserializable-body')
.expect(500, {
statusCode: 500,
message: 'Internal server error',
@@ -750,6 +750,20 @@ export class FastifyAdapter<
body = Readable.from([]);
}
}
// Fastify passes an Error payload to the error handler instead of
// sending it, and the reply around a raw response (e.g. the one
// middleware gets) has none. An Error body sent with an error status,
// as for `new UnauthorizedException(err)`, gets the standard body of the
// status instead, without the properties of the Error.
if (
body instanceof Error &&
fastifyReply.statusCode >= HttpStatus.BAD_REQUEST
) {
body = {
statusCode: fastifyReply.statusCode,
message: http.STATUS_CODES[fastifyReply.statusCode] ?? 'Error',
};
}
const responseContentType = fastifyReply.getHeader('Content-Type');
if (
typeof responseContentType === 'string' &&
@@ -119,6 +119,35 @@ describe('FastifyAdapter', () => {
);
});
// e.g. `new UnauthorizedException(err)`, whose response is `err`. On the
// raw response middleware gets, Fastify's reply has no error handler.
it.each([
['a raw response', true],
['a reply', false],
])(
'should send the standard body of the status for an Error body on %s',
async (_, raw) => {
fastifyAdapter.get('/', (_req: FastifyRequest, reply: FastifyReply) => {
const error = Object.assign(new Error('invalid signature'), {
config: { headers: { authorization: 'Bearer secret' } },
});
if (raw) {
reply.hijack();
}
fastifyAdapter.reply(raw ? reply.raw : reply, error, 401);
});
const res = await fastifyAdapter.inject({ method: 'GET', url: '/' });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({
statusCode: 401,
message: 'Unauthorized',
});
await fastifyAdapter.close();
},
);
it('should destroy the stream of a StreamableFile once the reply was sent', () => {
// e.g. by Fastify's `handlerTimeout`: send() then drops the stream
const reply = { ...createReply(), sent: true };