fix(express): stop reading a streamable file for a head request

Express answers HEAD requests with the GET route, so the stream of a
StreamableFile was piped into the HEAD response. Node discards the body
of a HEAD response without ever applying backpressure, so the pipe read
the whole source at full speed, whatever its size. Each HEAD request
cost as much disk and CPU time as a full download, without the client
having to receive anything.

For a HEAD request, the response now ends once the source is readable,
and the source is then destroyed. Waiting for it keeps the error
handler's answer to a source that cannot be opened, such as the 400 for
a missing file. The status and headers sent are unchanged.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-XZ3CL5Q9.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 15:07:13 +02:00
1 parent 97ab04494c
commit 8b8e8396f7
3 files changed
+79 -1

No files matched your search

@@ -73,6 +73,17 @@ describe('Express FileSend', () => {
it('should return an error if the file does not exist', async () => {
return request(app.getHttpServer()).get('/file/not/exist').expect(400);
});
it('should answer a HEAD request with the headers of a file', async () => {
return request(app.getHttpServer())
.head('/file/with/headers')
.expect(200)
.expect('Content-Type', 'text/markdown')
.expect('Content-Disposition', 'attachment; filename="Readme.md"')
.expect('Content-Length', readme.byteLength.toString());
});
it('should answer a HEAD request with an error if the file does not exist', async () => {
return request(app.getHttpServer()).head('/file/not/exist').expect(400);
});
// TODO: temporarily turned off (flaky test)
it.skip('should allow for the client to end the response and be able to make another', async () => {
const url = await getHttpBaseOptions(app);
@@ -188,6 +188,14 @@ export class ExpressAdapter extends AbstractHttpAdapter<
stream.destroy();
}
});
// Node discards the body of a HEAD response without ever applying
// backpressure, so piping would read the whole source. The response
// ends once the source is readable instead, which still leaves an
// error opening it (e.g. a missing file) to the error handler.
if (response.req?.method === 'HEAD') {
stream.once('readable', () => response.end());
return response.on('error', (err: Error) => body.errorLogger(err));
}
return stream
.pipe<Writable>(response)
.on('error', (err: Error) => body.errorLogger(err));
@@ -5,7 +5,7 @@ import {
} from '@nestjs/common';
import { ExpressAdapter } from '@nestjs/platform-express';
import express from 'express';
import { PassThrough } from 'stream';
import { PassThrough, Readable, Writable } from 'stream';
describe('ExpressAdapter', () => {
afterEach(() => vi.restoreAllMocks());
@@ -437,6 +437,65 @@ describe('ExpressAdapter', () => {
await new Promise(resolve => response.once('end', resolve));
expect(Buffer.concat(chunks).toString()).toBe('firstsecond');
});
describe('for a HEAD request', () => {
// Node discards what is written to a HEAD response, so its write()
// never applies backpressure
const createHeadResponse = () =>
Object.assign(
new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
{
req: { method: 'HEAD' },
getHeader: vi.fn(),
setHeader: vi.fn(),
},
);
it('should end the response without reading the whole source', async () => {
const chunkCount = 1000;
const chunk = Buffer.alloc(1024);
let reads = 0;
const source = new Readable({
read() {
this.push(++reads > chunkCount ? null : chunk);
},
});
const response = createHeadResponse();
expressAdapter.reply(response, new StreamableFile(source));
await new Promise(resolve => response.once('finish', resolve));
await vi.waitFor(() => expect(source.destroyed).toBe(true));
expect(reads).toBeLessThan(chunkCount);
});
it('should leave an error opening the source to the error handler', async () => {
const errorHandler = vi.fn();
const source = new Readable({
read() {
this.destroy(new Error('ENOENT'));
},
});
const response = createHeadResponse();
expressAdapter.reply(
response,
new StreamableFile(source).setErrorHandler(errorHandler),
);
await vi.waitFor(() =>
expect(errorHandler).toHaveBeenCalledWith(
expect.objectContaining({ message: 'ENOENT' }),
response,
),
);
expect(response.writableEnded).toBe(false);
});
});
});
});