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.
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-limitto 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.