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.
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.
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 });
}
});