Scanning multipart uploads with pompelmi and Multer
Multer is the standard multipart/form-data middleware for Node.js. This guide covers the full Multer configuration surface — DiskStorage, file filters, field-level scanning, size limits — and shows how pompelmi fits into each pattern.
The core rule: configure Multer to use DiskStorage (not the
default MemoryStorage) so that pompelmi always receives a path
on disk. Memory buffers require writing a temp file yourself before scanning;
DiskStorage avoids that extra step entirely.
DiskStorage configuration
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Always write to a dedicated temp directory
cb(null, os.tmpdir());
},
filename: (req, file, cb) => {
// Use a random hex prefix to avoid filename collisions and
// to prevent path traversal attacks in the original filename
const randomPrefix = crypto.randomBytes(8).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, randomPrefix + ext);
}
});
const upload = multer({ storage });
file.originalname directly as the filename on disk.
It is user-controlled and can contain path traversal sequences (../).
Always sanitize or replace the filename, as shown above.
File filter — pre-scan MIME type check
Multer's fileFilter runs before the file is written to disk.
Use it to reject obviously wrong content types immediately, before paying
the cost of a ClamAV scan.
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
'application/zip',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
]);
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.has(file.mimetype)) {
cb(null, true); // Accept
} else {
cb(new Error('File type not allowed: ' + file.mimetype), false);
}
},
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
});
file.mimetype comes from the Content-Type header
in the multipart part — it is user-supplied and trivially spoofable. MIME
filtering is a convenience gate, not a security control. ClamAV scanning
is the security control.
Single file upload with scanning
const express = require('express');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const path = require('path');
const app = express();
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file provided.' });
}
const { path: tmpPath, originalname } = req.file;
let verdict;
try {
verdict = await scan(tmpPath);
} catch (err) {
fs.unlinkSync(tmpPath);
return res.status(500).json({ error: 'Scan failed: ' + err.message });
}
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return res.status(400).json({ error: 'Malware detected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return res.status(422).json({ error: 'Scan incomplete. Rejected as precaution.' });
}
const dest = path.join('/var/app/uploads', req.file.filename);
fs.renameSync(tmpPath, dest);
res.json({ status: 'ok', file: originalname });
});
Scanning files from multiple named fields
upload.fields() accepts an array of field definitions.
req.files is then a map of field name to array of file objects.
Scan each field's files independently.
app.post(
'/submit',
upload.fields([
{ name: 'resume', maxCount: 1 },
{ name: 'coverLetter', maxCount: 1 }
]),
async (req, res) => {
const allFiles = [
...(req.files['resume'] || []),
...(req.files['coverLetter'] || [])
];
if (!allFiles.length) {
return res.status(400).json({ error: 'No files provided.' });
}
const accepted = [];
for (const file of allFiles) {
let verdict;
try {
verdict = await scan(file.path);
} catch (err) {
allFiles.forEach(f => { try { fs.unlinkSync(f.path); } catch (_) {} });
return res.status(500).json({ error: err.message });
}
if (verdict !== Verdict.Clean) {
// Delete all temp files and abort
allFiles.forEach(f => { try { fs.unlinkSync(f.path); } catch (_) {} });
const code = verdict === Verdict.Malicious ? 400 : 422;
return res.status(code).json({
error: verdict === Verdict.Malicious
? 'Malware detected in ' + file.originalname
: 'Scan incomplete for ' + file.originalname
});
}
const dest = path.join('/var/app/uploads', file.filename);
fs.renameSync(file.path, dest);
accepted.push({ field: file.fieldname, name: file.originalname });
}
res.json({ status: 'ok', files: accepted });
}
);
Size limits and error handling
When Multer's size limit is exceeded it calls next(err) with a
MulterError. Register an error middleware to catch it and return
a meaningful response.
const multer = require('multer');
// Error middleware — must be registered AFTER routes
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large. Maximum size is 20 MB.' });
}
return res.status(400).json({ error: 'Upload error: ' + err.message });
}
if (err.message === 'File type not allowed: ' + err.message.split(': ')[1]) {
return res.status(415).json({ error: err.message });
}
next(err); // Pass unknown errors to the default handler
});
Custom storage engine that scans on write
For tighter integration, implement a Multer custom storage engine. The
_handleFile method writes the upload to a temp file, scans it,
and either promotes it or rejects the upload before the route handler sees
the file.
// storage/scannedDiskStorage.js
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
class ScannedDiskStorage {
constructor(options = {}) {
this.destination = options.destination || os.tmpdir();
this.finalDir = options.finalDir || '/var/app/uploads';
}
_handleFile(req, file, cb) {
const tmpName = crypto.randomBytes(12).toString('hex') +
path.extname(file.originalname).toLowerCase();
const tmpPath = path.join(this.destination, tmpName);
const outStream = fs.createWriteStream(tmpPath);
file.stream.pipe(outStream);
outStream.on('error', cb);
outStream.on('finish', async () => {
let verdict;
try {
verdict = await scan(tmpPath);
} catch (err) {
fs.unlinkSync(tmpPath);
return cb(err);
}
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return cb(new Error('MALWARE_DETECTED'));
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return cb(new Error('SCAN_INCOMPLETE'));
}
// Clean — move to final directory immediately
const finalPath = path.join(this.finalDir, tmpName);
fs.rename(tmpPath, finalPath, (renameErr) => {
if (renameErr) return cb(renameErr);
cb(null, {
path: finalPath,
filename: tmpName,
originalname: file.originalname,
size: outStream.bytesWritten
});
});
});
}
_removeFile(req, file, cb) {
fs.unlink(file.path, cb);
}
}
module.exports = ScannedDiskStorage;
Use it as a drop-in Multer storage engine:
const ScannedDiskStorage = require('./storage/scannedDiskStorage');
const upload = multer({
storage: new ScannedDiskStorage({ finalDir: '/var/app/uploads' })
});
app.post('/upload', upload.single('file'), (req, res) => {
// req.file.path is already in finalDir — scan happened in the storage engine
res.json({ status: 'ok', file: req.file.originalname });
});