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 });
Never use 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 });
});