fix(express,fastify): handle a StreamableFile that cannot be streamed

On Express, a StreamableFile whose stream had already failed when
reply() ran (e.g. while an interceptor awaited) was piped as is, and
the response never ended: now that such an error no longer crashes the
process, the request was left unanswered. reply() now hands the error
to the file's error handler, as it does for a later one, so the client
gets the documented 400. This is a self-contained insertion at the top
of the StreamableFile block of ExpressAdapter.reply().

On Fastify, once the reply was sent (e.g. by handlerTimeout), send()
drops a stream without consuming or destroying it, so a file-backed
stream kept its descriptor open. reply() now destroys the stream first.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-6EC1G2GA.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 14:48:14 +02:00
1 parent cb1c0e8242
commit 1933cc9edd
4 files changed
+41 -1

No files matched your search

@@ -170,6 +170,12 @@ export class ExpressAdapter extends AbstractHttpAdapter<
return response.send();
}
if (body instanceof StreamableFile) {
// A stream that failed before the reply (e.g. while an interceptor
// awaited) has already emitted its 'error' event.
const { errored } = body.getStream();
if (errored) {
return body.errorHandler(errored, response);
}
this.applyStreamHeaders(response, body);
const stream = body.getStream();
stream.once('error', err => {
@@ -392,6 +392,22 @@ describe('ExpressAdapter', () => {
await vi.waitFor(() => expect(source.destroyed).toBe(true));
});
it('should report a source that failed before the reply to the error handler', async () => {
// e.g. ENOENT from `fs.createReadStream()` while an interceptor awaits
const source = new PassThrough();
const errorHandler = vi.fn();
const file = new StreamableFile(source).setErrorHandler(errorHandler);
const response = createStreamResponse();
const error = new Error('ENOENT');
source.destroy(error);
await new Promise(resolve => setImmediate(resolve));
expressAdapter.reply(response, file);
expect(errorHandler).toHaveBeenCalledWith(error, response);
expect(response.setHeader).not.toHaveBeenCalled();
});
it('should not destroy a source that has ended', async () => {
const source = new PassThrough({ autoDestroy: false });
const response = createStreamResponse();
@@ -682,6 +682,12 @@ export class FastifyAdapter<
fastifyReply.status(statusCode);
}
if (body instanceof StreamableFile) {
const stream = body.getStream();
// Once the reply was sent (e.g. by `handlerTimeout`), send() drops the
// stream without consuming or destroying it.
if (fastifyReply.sent && isFunction(stream?.destroy)) {
stream.destroy();
}
const streamHeaders = body.getHeaders();
if (
fastifyReply.getHeader('Content-Type') === undefined &&
@@ -701,7 +707,7 @@ export class FastifyAdapter<
) {
fastifyReply.header('Content-Length', streamHeaders.length);
}
body = body.getStream();
body = stream;
}
const responseContentType = fastifyReply.getHeader('Content-Type');
if (
@@ -10,12 +10,14 @@ import {
Logger,
RawBodyRequest,
RequestMethod,
StreamableFile,
VERSION_NEUTRAL,
VersioningOptions,
VersioningType,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest, fastify } from 'fastify';
import { inject } from 'light-my-request';
import { PassThrough } from 'stream';
describe('FastifyAdapter', () => {
let fastifyAdapter: FastifyAdapter;
@@ -111,6 +113,16 @@ describe('FastifyAdapter', () => {
'application/json',
);
});
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 };
const source = new PassThrough();
fastifyAdapter.reply(reply as any, new StreamableFile(source));
expect(source.destroyed).toBe(true);
});
});
describe('mapException', () => {