Skip to content

Commit a50c4f3

Browse files
feat(uploads): headersSent guard on stream errors + text/html MIME for GridFS (#3744)
Add res.headersSent check in get() and getSharp() stream error handlers so a GridFS failure mid-transfer (after headers are already flushed) destroys the socket via res.destroy(err) + logger.error instead of attempting a second status+body write that throws ERR_HTTP_HEADERS_SENT. Pre-header errors still return 422 as before. Add 'text/html': 'html' to MIME_TO_EXT so downstream features storing rendered HTML snapshots in GridFS get correct .html filenames without per-deployment config overrides. text/html is intentionally absent from SAFE_MIME, so the Content-Type allowlist + Content-Disposition: attachment XSS defenses remain intact. Port stream-error tests for the headersSent guard and add a MIME map test for text/html. Full unit suite: 1622/1622 pass. Adopted from trawl prod hardening; every devkit downstream gets it via /update-stack.
1 parent 0693e49 commit a50c4f3

4 files changed

Lines changed: 194 additions & 2 deletions

File tree

modules/uploads/controllers/uploads.controller.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import _ from 'lodash';
66

77
import config from '../../../config/index.js';
88
import errors from '../../../lib/helpers/errors.js';
9+
import logger from '../../../lib/services/logger.js';
910
import responses from '../../../lib/helpers/responses.js';
1011
import UploadsService from '../services/uploads.service.js';
1112

@@ -40,7 +41,19 @@ const get = async (req, res) => {
4041
const stream = await UploadsService.getStream({ _id: req.upload._id });
4142
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
4243
stream.on('error', (err) => {
43-
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
44+
// Guard against ERR_HTTP_HEADERS_SENT when GridFS fails mid-transfer:
45+
// `res.set('Content-Type', ...)` below flushes response headers to the
46+
// client before the stream starts. If the GridFS read then errors, a
47+
// second status+body write would throw ERR_HTTP_HEADERS_SENT and crash
48+
// the worker process. Pre-header errors still reach the client as 422;
49+
// post-header errors destroy the socket so Express surfaces them via
50+
// its default error handler instead of double-sending.
51+
if (!res.headersSent) {
52+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
53+
} else {
54+
logger.error('uploads.get - stream error after headers sent', err);
55+
res.destroy(err);
56+
}
4457
});
4558
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
4659
const norm = normalizeMime(raw);
@@ -65,7 +78,17 @@ const getSharp = async (req, res) => {
6578
const stream = await UploadsService.getStream({ _id: req.upload._id });
6679
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
6780
stream.on('error', (err) => {
68-
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
81+
// Same headersSent guard as `get` above. A sharp pipeline error after
82+
// `res.set('Content-Type', ...)` has flushed the response head cannot
83+
// write a new status or body — attempting to do so throws
84+
// ERR_HTTP_HEADERS_SENT. Destroy the socket instead so Express surfaces
85+
// the error via its default handler without double-sending.
86+
if (!res.headersSent) {
87+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
88+
} else {
89+
logger.error('uploads.getSharp - stream error after headers sent', err);
90+
res.destroy(err);
91+
}
6992
});
7093
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
7194
const norm = normalizeMime(raw);

modules/uploads/services/uploads.service.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const MIME_TO_EXT = {
1515
'image/png': 'png',
1616
'image/gif': 'gif',
1717
'application/pdf': 'pdf',
18+
// Allows downstream features that store rendered HTML snapshots in GridFS
19+
// alongside binary uploads (e.g. error pages, scrap previews).
20+
'text/html': 'html',
1821
};
1922

2023
/**

modules/uploads/tests/uploads.controller.contenttype.unit.tests.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
3535
};
3636
};
3737

38+
let mockLogger;
39+
3840
beforeEach(async () => {
3941
jest.resetModules();
4042

@@ -44,6 +46,8 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
4446
remove: jest.fn(),
4547
};
4648

49+
mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() };
50+
4751
jest.unstable_mockModule('../services/uploads.service.js', () => ({
4852
default: mockUploadsService,
4953
}));
@@ -76,6 +80,10 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
7680
},
7781
}));
7882

83+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
84+
default: mockLogger,
85+
}));
86+
7987
const mod = await import('../controllers/uploads.controller.js');
8088
UploadsController = mod.default;
8189
});
@@ -360,4 +368,144 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
360368
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
361369
});
362370
});
371+
372+
// ── get — headersSent guard ───────────────────────────────────────────────
373+
374+
describe('get — headersSent guard on stream error', () => {
375+
/**
376+
* Install a fake stream that captures the `error` listener so tests can
377+
* fire it manually — simulating either a pre-flush or mid-flush GridFS
378+
* failure depending on `res.headersSent`.
379+
*/
380+
const installStream = () => {
381+
const listeners = {};
382+
const stream = {
383+
pipe: jest.fn().mockReturnThis(),
384+
on: jest.fn((event, handler) => {
385+
listeners[event] = handler;
386+
return stream;
387+
}),
388+
};
389+
mockUploadsService.getStream.mockResolvedValueOnce(stream);
390+
return { stream, listeners };
391+
};
392+
393+
const buildRes = () => {
394+
const res = {};
395+
res.status = jest.fn().mockReturnValue(res);
396+
res.json = jest.fn().mockReturnValue(res);
397+
res.set = jest.fn();
398+
res.destroy = jest.fn();
399+
res.headersSent = false;
400+
return res;
401+
};
402+
403+
const req = {
404+
upload: {
405+
_id: 'u1',
406+
contentType: 'image/jpeg',
407+
length: 1234,
408+
metadata: { contentType: 'image/jpeg' },
409+
},
410+
};
411+
412+
test('responds via responses.error when stream errors before headers are sent', async () => {
413+
const { listeners } = installStream();
414+
const { default: responses } = await import('../../../lib/helpers/responses.js');
415+
const res = buildRes();
416+
417+
await UploadsController.get(req, res);
418+
419+
res.headersSent = false;
420+
listeners.error(new Error('gridfs chunk missing'));
421+
422+
expect(responses.error).toHaveBeenCalled();
423+
expect(res.destroy).not.toHaveBeenCalled();
424+
});
425+
426+
test('calls res.destroy(err) and logger.error when stream errors after headers are sent', async () => {
427+
const { listeners } = installStream();
428+
const { default: responses } = await import('../../../lib/helpers/responses.js');
429+
const res = buildRes();
430+
431+
await UploadsController.get(req, res);
432+
433+
res.headersSent = true;
434+
const err = new Error('gridfs mid-stream abort');
435+
listeners.error(err);
436+
437+
// Must NOT attempt a new response body on an already-flushed response.
438+
expect(responses.error).not.toHaveBeenCalled();
439+
expect(res.destroy).toHaveBeenCalledWith(err);
440+
// Error must reach the logger so mid-stream GridFS failures are visible.
441+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('uploads.get'), err);
442+
});
443+
});
444+
445+
// ── getSharp — headersSent guard ──────────────────────────────────────────
446+
447+
describe('getSharp — headersSent guard on stream error', () => {
448+
const installStream = () => {
449+
const listeners = {};
450+
const stream = {
451+
pipe: jest.fn().mockReturnThis(),
452+
on: jest.fn((event, handler) => {
453+
listeners[event] = handler;
454+
return stream;
455+
}),
456+
};
457+
mockUploadsService.getStream.mockResolvedValueOnce(stream);
458+
return { stream, listeners };
459+
};
460+
461+
const buildRes = () => {
462+
const res = {};
463+
res.status = jest.fn().mockReturnValue(res);
464+
res.json = jest.fn().mockReturnValue(res);
465+
res.set = jest.fn();
466+
res.destroy = jest.fn();
467+
res.headersSent = false;
468+
return res;
469+
};
470+
471+
const req = {
472+
upload: {
473+
_id: 'u1',
474+
contentType: 'image/jpeg',
475+
metadata: { contentType: 'image/jpeg' },
476+
},
477+
sharpSize: null,
478+
sharpOption: null,
479+
};
480+
481+
test('responds via responses.error when stream errors before headers are sent', async () => {
482+
const { listeners } = installStream();
483+
const { default: responses } = await import('../../../lib/helpers/responses.js');
484+
const res = buildRes();
485+
486+
await UploadsController.getSharp(req, res);
487+
488+
res.headersSent = false;
489+
listeners.error(new Error('early gridfs failure'));
490+
491+
expect(responses.error).toHaveBeenCalled();
492+
expect(res.destroy).not.toHaveBeenCalled();
493+
});
494+
495+
test('calls res.destroy(err) and logger.error when stream errors after headers are sent', async () => {
496+
const { listeners } = installStream();
497+
const { default: responses } = await import('../../../lib/helpers/responses.js');
498+
const res = buildRes();
499+
500+
await UploadsController.getSharp(req, res);
501+
502+
res.headersSent = true;
503+
const err = new Error('sharp pipeline mid-stream abort');
504+
listeners.error(err);
505+
506+
expect(responses.error).not.toHaveBeenCalled();
507+
expect(res.destroy).toHaveBeenCalledWith(err);
508+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('uploads.getSharp'), err);
509+
});
510+
});
363511
});

modules/uploads/tests/uploads.createFromBuffer.unit.tests.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,24 @@ describe('Uploads createFromBuffer unit tests:', () => {
175175
expect(filename).toMatch(/^[a-f0-9]{64}\.html$/);
176176
});
177177

178+
test('should resolve text/html to .html extension via built-in MIME_TO_EXT (no config.uploads.mimeTypes needed)', async () => {
179+
// text/html is in the built-in MIME_TO_EXT map so downstream features that
180+
// store rendered HTML snapshots in GridFS get a correct .html filename
181+
// without requiring per-deployment mimeTypes config.
182+
mockConfig.uploads.snapshot = {
183+
kind: 'snapshot',
184+
formats: ['text/html'],
185+
limits: { fileSize: 1 * 1024 * 1024 },
186+
};
187+
mockGridfs.createFromBuffer.mockResolvedValue({ ...fakeFile, contentType: 'text/html' });
188+
189+
const buffer = Buffer.alloc(512);
190+
await UploadsService.createFromBuffer(buffer, 'text/html', 'snapshot');
191+
192+
const [, filename] = mockGridfs.createFromBuffer.mock.calls[0];
193+
expect(filename).toMatch(/^[a-f0-9]{64}\.html$/);
194+
});
195+
178196
test('should throw error when kind has no formats configured', async () => {
179197
// Adding 'broken' kind at runtime — service reads config dynamically via module reference
180198
mockConfig.uploads.broken = { kind: 'broken', limits: { fileSize: 1024 } };

0 commit comments

Comments
 (0)