Scanning file uploads with pompelmi in Express.js

Express.js is the most widely deployed Node.js web framework. This guide shows how to add antivirus scanning to any file upload route using Multer for multipart handling and pompelmi for ClamAV-backed scanning.

pompelmi scans files already written to disk and returns a typed Verdict Symbol. The integration pattern is therefore straightforward: let Multer save the file, scan it, then either promote it to permanent storage or delete it and reject the request.

This guide assumes ClamAV is installed and freshclam has been run. See the Quickstart if you need those steps.

Install

npm install express multer pompelmi

pompelmi has zero runtime dependencies. Multer is the only additional package needed.

Basic upload route

Configure Multer to write uploads to a temporary directory, then scan before moving the file to permanent storage.

const express = require('express');
const multer  = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs   = require('fs');

const app = express();

// Multer writes the raw upload to /tmp/uploads before the route handler runs
const upload = multer({ dest: '/tmp/uploads/' });

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file provided.' });
  }

  const tmpPath  = req.file.path;
  const destPath = path.join('/var/app/uploads', req.file.originalname);

  try {
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      fs.unlinkSync(tmpPath);
      return res.status(400).json({ error: 'Malware detected. Upload rejected.' });
    }

    if (verdict === Verdict.ScanError) {
      fs.unlinkSync(tmpPath);
      return res.status(422).json({ error: 'Scan could not complete. Upload rejected as a precaution.' });
    }

    // Verdict.Clean — move to permanent storage
    fs.renameSync(tmpPath, destPath);
    res.json({ status: 'ok', file: req.file.originalname });

  } catch (err) {
    // clamscan binary missing, file not found, or other OS error
    fs.unlinkSync(tmpPath);
    res.status(500).json({ error: 'Scan failed: ' + err.message });
  }
});

app.listen(3000, () => console.log('Listening on :3000'));

Multer gives you req.file.path — the absolute path on disk where the upload was buffered. Pass that path directly to scan(). No stream wrangling required.

Use multer({ dest: os.tmpdir() }) to write to the OS temporary directory rather than a hard-coded path. This works correctly across macOS, Linux, and Windows.

Reliable file clean-up

The pattern above deletes the temp file in every error branch but duplicates the clean-up logic. A finally block avoids leaving orphaned files if an unexpected code path is hit.

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file provided.' });
  }

  const tmpPath = req.file.path;
  let promoted  = false;

  try {
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return res.status(400).json({ error: 'Malware detected.' });
    }
    if (verdict === Verdict.ScanError) {
      return res.status(422).json({ error: 'Scan incomplete. Rejected as precaution.' });
    }

    const dest = path.join('/var/app/uploads', req.file.originalname);
    fs.renameSync(tmpPath, dest);
    promoted = true;
    res.json({ status: 'ok', file: req.file.originalname });

  } catch (err) {
    res.status(500).json({ error: err.message });
  } finally {
    if (!promoted && fs.existsSync(tmpPath)) {
      fs.unlinkSync(tmpPath);
    }
  }
});

Scanning multiple files

Use upload.array('files', 10) to accept up to 10 files per request. Scan them in sequence to avoid spawning many clamscan processes simultaneously (each process loads the full ClamAV database into memory).

app.post('/upload-many', upload.array('files', 10), async (req, res) => {
  if (!req.files || req.files.length === 0) {
    return res.status(400).json({ error: 'No files provided.' });
  }

  const results = [];

  for (const file of req.files) {
    let verdict;
    try {
      verdict = await scan(file.path);
    } catch (err) {
      fs.unlinkSync(file.path);
      return res.status(500).json({ error: 'Scan error on ' + file.originalname + ': ' + err.message });
    }

    if (verdict === Verdict.Malicious) {
      // Delete the malicious file and all already-processed temp files
      req.files.forEach(f => { try { fs.unlinkSync(f.path); } catch (_) {} });
      return res.status(400).json({ error: 'Malware detected in ' + file.originalname });
    }

    if (verdict === Verdict.ScanError) {
      fs.unlinkSync(file.path);
      return res.status(422).json({ error: 'Scan incomplete for ' + file.originalname });
    }

    const dest = path.join('/var/app/uploads', file.originalname);
    fs.renameSync(file.path, dest);
    results.push(file.originalname);
  }

  res.json({ status: 'ok', files: results });
});

Reusable scan middleware

When multiple routes accept uploads, extract the scan logic into a reusable Express middleware. The middleware adds req.scanVerdict for the next handler to inspect, and handles cleanup on rejection.

// middleware/scanUpload.js
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');

/**
 * Express middleware that scans req.file (single upload) with pompelmi.
 * Adds req.scanVerdict on success.
 * Sends 400/422/500 and deletes the temp file on failure.
 */
async function scanUpload(req, res, next) {
  if (!req.file) return next();

  try {
    const verdict = await scan(req.file.path);

    if (verdict === Verdict.Malicious) {
      fs.unlinkSync(req.file.path);
      return res.status(400).json({ error: 'Malware detected. Upload rejected.' });
    }

    if (verdict === Verdict.ScanError) {
      fs.unlinkSync(req.file.path);
      return res.status(422).json({ error: 'Scan incomplete. Upload rejected as precaution.' });
    }

    req.scanVerdict = verdict; // Verdict.Clean
    next();

  } catch (err) {
    if (req.file && fs.existsSync(req.file.path)) {
      fs.unlinkSync(req.file.path);
    }
    next(err);
  }
}

module.exports = { scanUpload };

Apply it after the Multer middleware on any route:

const { scanUpload } = require('./middleware/scanUpload');

app.post('/documents', upload.single('doc'), scanUpload, (req, res) => {
  const dest = path.join('/var/app/documents', req.file.originalname);
  fs.renameSync(req.file.path, dest);
  res.json({ status: 'ok', file: req.file.originalname });
});

app.post('/images', upload.single('image'), scanUpload, (req, res) => {
  const dest = path.join('/var/app/images', req.file.originalname);
  fs.renameSync(req.file.path, dest);
  res.json({ status: 'ok', file: req.file.originalname });
});

TypeScript

pompelmi ships type declarations. Import using ES module syntax:

import express, { Request, Response } from 'express';
import multer from 'multer';
import { scan, Verdict } from 'pompelmi';
import path from 'path';
import fs from 'fs';

const app = express();
const upload = multer({ dest: '/tmp/uploads/' });

app.post('/upload', upload.single('file'), async (req: Request, res: Response): Promise<void> => {
  if (!req.file) {
    res.status(400).json({ error: 'No file provided.' });
    return;
  }

  const tmpPath = req.file.path;

  try {
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      fs.unlinkSync(tmpPath);
      res.status(400).json({ error: 'Malware detected.' });
      return;
    }

    if (verdict === Verdict.ScanError) {
      fs.unlinkSync(tmpPath);
      res.status(422).json({ error: 'Scan incomplete.' });
      return;
    }

    const dest = path.join('/var/app/uploads', req.file.originalname);
    fs.renameSync(tmpPath, dest);
    res.json({ status: 'ok', file: req.file.originalname });

  } catch (err: unknown) {
    if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
    const message = err instanceof Error ? err.message : String(err);
    res.status(500).json({ error: message });
  }
});