mirror of
https://github.com/nestjs/nest.git
synced 2026-10-29 02:06:11 +08:00
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:
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', () => {
|
||||
|
||||
Reference in new issue
Block a user