mirror of
https://github.com/nestjs/nest.git
synced 2026-10-28 17:56:01 +08:00
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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user