Works with any backend npm install Zero dependencies

Drop-in virus-scan proxy - POST /scan in multipleupload/server

The Express/Connect middleware shipped at multipleupload/server now exposes POST /scan as a pass-through endpoint. Wire it to your scanner of choice (ClamAV, VirusTotal, Microsoft Defender, etc.) via the scanFile option. The browser client's virusScan hook calls /scan per file and uses the verdict to allow or reject the upload.

End-to-end story in 8 lines: the client hook posts the file to /scan; your scanFile callback runs whatever scanner; the verdict comes back; client decides upload vs reject - all without writing route handlers, multipart parsers, or fan-out logic.
Install
npm install multipleupload express busboy
Server: mount with a scanner callback
const express = require('express');
const multipleupload = require('multipleupload/server');

const app = express();
app.use(multipleupload({
 uploadDir: './uploads',

 scanFile: async (buffer, fileName) => {
 // Replace with any scanner. Returns { clean: boolean, threat?: string }
 return runYourScanner(buffer, fileName);
 }
}));

app.listen(3000);
Client: opt in via the existing virusScan hook
new MultipleUpload('#uploader', {
 uploadUrl: '/api/upload',
 virusScan: async (file) => {
 const fd = new FormData();
 fd.append('file', file);
 const res = await fetch('/api/scan', { method: 'POST', body: fd });
 const verdict = await res.json();
 return verdict.clean === true ? true : `Threat: ${verdict.threat || 'unknown'}`;
 }
});
Recipe: ClamAV via clamscan
const NodeClam = require('clamscan');

const clamAVPromise = new NodeClam().init({
 clamdscan: { host: 'localhost', port: 3310 } // assumes clamd daemon
});

app.use(multipleupload({
 uploadDir: './uploads',
 scanFile: async (buffer, fileName) => {
 const clamscan = await clamAVPromise;
 const { isInfected, viruses } = await clamscan.scanStream(
 require('stream').Readable.from(buffer)
 );
 return {
 clean: !isInfected,
 threat: isInfected ? viruses.join(', ') : null
 };
 }
}));
Recipe: VirusTotal hash lookup (fast-path) + upload on miss
const crypto = require('node:crypto');

app.use(multipleupload({
 uploadDir: './uploads',
 scanFile: async (buffer, fileName) => {
 // Fast path: hash lookup (sub-second when cached)
 const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
 const cached = await fetch(`https://www.virustotal.com/api/v3/files/${sha256}`, {
 headers: { 'x-apikey': process.env.VT_API_KEY }
 }).then(r => r.ok ? r.json() : null).catch(() => null);

 if (cached?.data?.attributes?.last_analysis_stats?.malicious > 3) {
 return { clean: false, threat: `VirusTotal: ${cached.data.attributes.last_analysis_stats.malicious} engines flagged` };
 }
 if (cached) return { clean: true };

 // Cache miss: optionally upload to VT for fresh analysis (60s+) or
 // soft-pass and rely on server-side scanning at storage time.
 return { clean: true };
 }
}));
Recipe: Microsoft Defender via mpcmdrun.exe (Windows hosts)
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const exec = promisify(execFile);

app.use(multipleupload({
 uploadDir: './uploads',
 scanFile: async (buffer, fileName) => {
 const tmpFile = path.join(os.tmpdir(), `mu-scan-${Date.now()}-${fileName}`);
 await fs.promises.writeFile(tmpFile, buffer);
 try {
 await exec('C:\\\\Program Files\\\\Windows Defender\\\\MpCmdRun.exe',
 ['-Scan', '-ScanType', '3', '-File', tmpFile, '-DisableRemediation']);
 return { clean: true };
 } catch (err) {
 // Non-zero exit on threat detected; parse stdout for the threat name.
 const out = String(err.stdout || '');
 const match = out.match(/Threat\s+:\s+(\S+)/i);
 return { clean: false, threat: match ? match[1] : 'Defender flagged file' };
 } finally {
 fs.promises.unlink(tmpFile).catch(() => {});
 }
 }
}));
Response shape
// 200 OK
{
 "clean": true | false,
 "threat": "EICAR-Test-Signature" | null,
 "scannedAt": "2026-05-11T17:23:45.123Z",
 "scannedBytes": 68
}

// 400 - no file in request
{ "clean": false, "error": "no_file", "threat": null }

// 413 - exceeds maxFileSize
{ "clean": false, "error": "file_too_large", "threat": null }

// 500 - scanner threw
{ "clean": false, "error": "<scanner error message>", "threat": null }

// 501 - scanFile not configured
{ "clean": false, "error": "scan_not_configured", "threat": null }
Default behaviour without scanFile

If you mount the middleware without a scanFile callback, POST /scan returns 501 with scan_not_configured. The client's virusScan hook can detect this and soft-pass (treat as clean) or hard-block (treat as failure) per your policy. This makes it safe to deploy the endpoint conditionally - turn scanning on per-environment without changing routes.

Hardening checklist
  • Rate-limit /scan: wrap the middleware with express-rate-limit to prevent abuse. Scan calls are expensive.
  • Pre-check by hash: the VirusTotal recipe above is fastest - cached hash lookups are sub-second.
  • Cap maxFileSize: the same limit applies to /scan as /upload, so massive files won't OOM the scanner.
  • Add auth: mount the middleware behind your auth layer. Anonymous /scan calls aren't typically what you want.
  • Tighten allowedExtensions: rejects unwanted file types BEFORE the scan, saving cycles.

Closes the loop on the virusScan hook from a previous iteration - the client side was always ready; this iteration ships the server-side counterpart.