Node.js file upload security checklist (with virus scanning)
File uploads are a high-risk surface. This checklist covers every control you should have in place before accepting uploads from untrusted users. Each item links to code you can copy directly.
| # | Control | Prevents |
|---|---|---|
| 1 | Enforce file size limits before writing to disk | DoS, disk exhaustion, zip bombs |
| 2 | Validate content type via magic bytes | File type spoofing, disguised executables |
| 3 | Never use the original filename on disk | Path traversal, overwrite attacks |
| 4 | Scan with ClamAV via pompelmi | Viruses, trojans, ransomware, malicious macros |
| 5 | Store uploads in an isolated location | Code execution, accidental exposure |
| 6 | Serve with correct headers | XSS via SVG/HTML uploads, MIME sniffing |
| 7 | Log rejected uploads | Blind spots in incident response |
1. Enforce file size limits before writing to disk
Configure your multipart parser to enforce a hard size limit. With Multer,
fileSize is enforced while streaming — the upload is rejected
before it's fully written.
const multer = require('multer');
const os = require('os');
const upload = multer({
dest: os.tmpdir(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB
files: 5 // max 5 files per request
}
});
// Catch Multer errors in an error middleware
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large. Limit is 10 MB.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files.' });
}
next(err);
});
2. Validate content type via magic bytes
Check the actual bytes at the start of the file, not just the
Content-Type header or file extension — both are user-controlled.
const { fileTypeFromFile } = require('file-type'); // npm install file-type
const ALLOWED_MIME = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // .xlsx
]);
async function validateMimeType(filePath) {
const type = await fileTypeFromFile(filePath);
if (!type || !ALLOWED_MIME.has(type.mime)) {
throw Object.assign(
new Error('File type not permitted: ' + (type ? type.mime : 'unknown')),
{ statusCode: 415 }
);
}
return type;
}
3. Never use the original filename on disk
The original filename is user input. Treat it as untrusted and never use it as a filesystem path. Generate a random name for storage; keep the original in your database if you need to display it.
const crypto = require('crypto');
const path = require('path');
function generateSafeFilename(mimeType, ext) {
// ext comes from magic byte detection, not user input
return crypto.randomBytes(20).toString('hex') + (ext ? '.' + ext : '');
}
// Usage:
const type = await validateMimeType(tmpPath); // from step 2
const safeName = generateSafeFilename(type.mime, type.ext);
// e.g. "a3f5c8d2...b7.pdf"
4. Scan with ClamAV via pompelmi
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
async function antivirusScan(tmpPath) {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
throw Object.assign(new Error('Malware detected. Upload rejected.'), { statusCode: 400 });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
throw Object.assign(
new Error('Scan incomplete. File rejected as a precaution.'),
{ statusCode: 422 }
);
}
// Verdict.Clean — continue
}
ScanError. An attacker may intentionally
cause scan errors (e.g. by submitting a deeply nested archive) to bypass scanning.
Treat any non-Clean verdict as untrusted.
Not set up yet? See Getting started with antivirus scanning in Node.js.
5. Store uploads in an isolated location
Don't store uploads inside your web root, your application directory, or any directory your web server might serve or execute. Keep them in a dedicated directory with restricted permissions.
// Bad — accessible via your web server's static file serving
const dest = path.join(__dirname, 'public', 'uploads', safeName);
// Good — outside the web root, only readable by the app user
const dest = path.join('/var/app/uploads', safeName);
fs.renameSync(tmpPath, dest);
On Linux, ensure the upload directory is owned by the application user and not writable by the web server process:
sudo mkdir -p /var/app/uploads sudo chown appuser:appuser /var/app/uploads sudo chmod 750 /var/app/uploads
6. Serve uploads with correct HTTP headers
An SVG file or HTML file that passes virus scanning can still execute JavaScript in the browser if served inline from your main origin. Force download mode and prevent MIME sniffing.
app.get('/files/:id', async (req, res) => {
const record = await db.files.findById(req.params.id);
if (!record) return res.status(404).end();
const filePath = path.join('/var/app/uploads', record.storedName);
// Force download — prevents browser from rendering HTML/SVG inline
res.setHeader('Content-Disposition',
`attachment; filename="${record.originalName}"`);
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Set the correct type from the database (magic-byte verified at upload time)
res.setHeader('Content-Type', record.mimeType);
res.sendFile(filePath);
});
7. Log rejected uploads
Log every rejected upload with enough context to investigate later. At minimum: IP address, timestamp, original filename, MIME type, and reason for rejection.
function logRejection(req, file, reason) {
console.warn(JSON.stringify({
event: 'upload_rejected',
ip: req.ip,
originalName: file ? file.originalname : null,
mimeType: file ? file.mimetype : null,
size: file ? file.size : null,
reason,
}));
}
Putting it all together — complete upload handler
const express = require('express');
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const { fileTypeFromFile } = require('file-type');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const os = require('os');
const app = express();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 10 * 1024 * 1024, files: 1 }
});
const ALLOWED_MIME = new Set(['image/jpeg','image/png','application/pdf']);
function logRejection(req, file, reason) {
console.warn(JSON.stringify({ event: 'upload_rejected', ip: req.ip,
name: file?.originalname, mime: file?.mimetype, reason }));
}
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 {
// 1. MIME validation
const type = await fileTypeFromFile(tmpPath);
if (!type || !ALLOWED_MIME.has(type.mime)) {
logRejection(req, req.file, 'invalid_mime:' + (type?.mime || 'unknown'));
return res.status(415).json({ error: 'File type not permitted.' });
}
// 2. Virus scan
const verdict = await scan(tmpPath);
if (verdict !== Verdict.Clean) {
logRejection(req, req.file, verdict.description);
return res.status(verdict === Verdict.Malicious ? 400 : 422).json({
error: verdict === Verdict.Malicious
? 'Malware detected.'
: 'Scan incomplete. Rejected as precaution.'
});
}
// 3. Safe filename + isolated storage
const safeName = crypto.randomBytes(20).toString('hex') + '.' + type.ext;
const dest = path.join('/var/app/uploads', safeName);
fs.renameSync(tmpPath, dest);
promoted = true;
// 4. Persist record to DB (pseudo-code)
// await db.files.create({ id: safeName, originalName: req.file.originalname, mimeType: type.mime });
res.json({ status: 'ok', id: safeName });
} catch (err) {
logRejection(req, req.file, 'error:' + err.message);
res.status(err.statusCode || 500).json({ error: err.message });
} finally {
if (!promoted && fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large.' });
}
next(err);
});