Skip to content

Commit 2ff4161

Browse files
feat(uploads): headersSent guard on stream errors + text/html MIME for GridFS (#3745)
* 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. * fix(uploads): add return on null-stream 404 + guard sharp transform errors Address two CodeRabbit findings: 1. Add return before responses.error(res, 404) in both get() and getSharp() so execution does not fall through to stream.on() when getStream returns falsy — preventing a TypeError and ERR_HTTP_HEADERS_SENT crash on the null-stream path. 2. Attach the headersSent-gated error handler to the sharp Transform (not just the GridFS source stream). pipe() does not forward error events between streams, so sharp-pipeline failures were unhandled and could crash the worker process. Refactored to buildTransform() helper that creates the transform, attaches the error handler, and returns it. Update comment to accurately describe when headersSent flips to true (first chunk written, not res.set()). Add test: sharp transform error after headers sent → res.destroy + logger. Full unit suite: 1623/1623 pass.
1 parent 0693e49 commit 2ff4161

4 files changed

Lines changed: 244 additions & 19 deletions

File tree

modules/uploads/controllers/uploads.controller.js

Lines changed: 50 additions & 19 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

@@ -38,9 +39,21 @@ const normalizeMime = (raw) => String(raw).toLowerCase().split(';')[0].trim();
3839
const get = async (req, res) => {
3940
try {
4041
const stream = await UploadsService.getStream({ _id: req.upload._id });
41-
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
42+
if (!stream) return 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+
// Response headers are written when the first chunk is piped to res
46+
// (`stream.pipe(res)` below). If GridFS errors after that point, 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);
@@ -63,27 +76,45 @@ const get = async (req, res) => {
6376
const getSharp = async (req, res) => {
6477
try {
6578
const stream = await UploadsService.getStream({ _id: req.upload._id });
66-
if (!stream) responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
67-
stream.on('error', (err) => {
68-
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
69-
});
79+
if (!stream) return responses.error(res, 404, 'Not Found', 'No Upload with that identifier can been found')();
80+
// headersSent guard for the GridFS source stream.
81+
// pipe() does not forward 'error' events between streams; each stream in the
82+
// chain needs its own listener. Source and Transform errors are handled below.
83+
// Pre-header errors reach the client as 422; post-header errors destroy the
84+
// socket so Express surfaces them via its default handler instead of
85+
// attempting a second write that would throw ERR_HTTP_HEADERS_SENT.
86+
const onStreamError = (label) => (err) => {
87+
if (!res.headersSent) {
88+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
89+
} else {
90+
logger.error(`uploads.getSharp - ${label} error after headers sent`, err);
91+
res.destroy(err);
92+
}
93+
};
94+
stream.on('error', onStreamError('source stream'));
7095
const raw = req.upload.contentType || req.upload.metadata?.contentType || 'application/octet-stream';
7196
const norm = normalizeMime(raw);
7297
const contentType = SAFE_IMAGE_MIME.has(norm) ? norm : 'image/jpeg';
7398
res.set('Content-Type', contentType);
74-
switch (req.sharpOption) {
75-
case 'blur':
76-
stream.pipe(sharp().resize(req.sharpSize).blur(config.uploads.sharp.blur)).pipe(res);
77-
break;
78-
case 'bw':
79-
stream.pipe(sharp().resize(req.sharpSize).grayscale()).pipe(res);
80-
break;
81-
case 'blur&bw':
82-
stream.pipe(sharp().resize(req.sharpSize).grayscale().blur(config.uploads.sharp.blur)).pipe(res);
83-
break;
84-
default:
85-
stream.pipe(sharp().resize(req.sharpSize)).pipe(res);
86-
}
99+
const buildTransform = () => {
100+
let transform;
101+
switch (req.sharpOption) {
102+
case 'blur':
103+
transform = sharp().resize(req.sharpSize).blur(config.uploads.sharp.blur);
104+
break;
105+
case 'bw':
106+
transform = sharp().resize(req.sharpSize).grayscale();
107+
break;
108+
case 'blur&bw':
109+
transform = sharp().resize(req.sharpSize).grayscale().blur(config.uploads.sharp.blur);
110+
break;
111+
default:
112+
transform = sharp().resize(req.sharpSize);
113+
}
114+
transform.on('error', onStreamError('sharp transform'));
115+
return transform;
116+
};
117+
stream.pipe(buildTransform()).pipe(res);
87118
} catch (err) {
88119
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
89120
}

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: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
3535
};
3636
};
3737

38+
let mockLogger;
39+
// Captures the 'error' listener registered on the sharp Transform so tests
40+
// can fire it manually to simulate a sharp-pipeline mid-stream failure.
41+
let sharpListeners;
42+
3843
beforeEach(async () => {
3944
jest.resetModules();
4045

@@ -44,6 +49,9 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
4449
remove: jest.fn(),
4550
};
4651

52+
mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() };
53+
sharpListeners = {};
54+
4755
jest.unstable_mockModule('../services/uploads.service.js', () => ({
4856
default: mockUploadsService,
4957
}));
@@ -56,12 +64,16 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
5664
// sharp is a real dependency; mock it to avoid needing native bindings in unit context.
5765
// pipe must return `dest` (matching the real Stream.pipe contract) so that chained
5866
// calls stream.pipe(transform).pipe(res) work without throwing.
67+
// on() captures listeners so tests can fire transform error events.
5968
jest.unstable_mockModule('sharp', () => ({
6069
default: jest.fn(() => ({
6170
resize: jest.fn().mockReturnThis(),
6271
blur: jest.fn().mockReturnThis(),
6372
grayscale: jest.fn().mockReturnThis(),
6473
pipe: jest.fn((dest) => dest),
74+
on: jest.fn((event, handler) => {
75+
sharpListeners[event] = handler;
76+
}),
6577
})),
6678
}));
6779

@@ -76,6 +88,10 @@ describe('Uploads controller content-type allowlist unit tests:', () => {
7688
},
7789
}));
7890

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

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)