How to protect your Node.js app from malicious file uploads
Accepting file uploads is one of the riskiest things a web application can do. Users can upload more than you expect: files with misleading extensions, files with path traversal sequences in the name, enormous files designed to exhaust disk space, and files with malware embedded in them.
This guide walks through each category of threat, explains why it matters, and shows you the exact code to defend against it.
Threat 1 — MIME type and extension spoofing
A file's MIME type (from the Content-Type header) and its
extension are both user-controlled. An attacker can upload a PHP script
named profile.jpg with Content-Type: image/jpeg.
If your server saves it and the web server later executes it, you have a
remote code execution vulnerability.
Defence: validate the magic bytes
Don't trust the file extension or MIME type header. Check the actual bytes
at the start of the file (the "magic bytes") to confirm the format. The
file-type package does this:
npm install file-type
const { fileTypeFromFile } = require('file-type');
const ALLOWED_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf'
]);
async function checkMagicBytes(filePath) {
const type = await fileTypeFromFile(filePath);
if (!type || !ALLOWED_TYPES.has(type.mime)) {
throw new Error(
'Rejected: actual file type is ' + (type ? type.mime : 'unknown')
);
}
return type; // { ext: 'jpg', mime: 'image/jpeg' }
}
Threat 2 — Path traversal via filename
If your server uses the original filename when saving to disk, an attacker
can upload a file named ../../etc/passwd or
../index.html. Depending on your storage logic this can
overwrite arbitrary files on your server.
Defence: never use the original filename directly
const crypto = require('crypto');
const path = require('path');
function safeSavedFilename(originalName) {
// Generate a random name — completely ignores the original
const random = crypto.randomBytes(16).toString('hex');
const ext = path.extname(originalName).toLowerCase().slice(0, 6); // max 6 chars
// Whitelist safe extensions only
const SAFE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.docx']);
const safeExt = SAFE_EXTS.has(ext) ? ext : '';
return random + safeExt; // e.g. "a3f7c2d1...b4.pdf"
}
Store the original filename separately in your database if you need to show it to the user — never use it as an actual path on disk.
Threat 3 — Oversized files and zip bombs
A zip bomb is a small compressed file that expands to a huge size when decompressed — a 1 KB file that expands to 10 GB can exhaust disk space and bring down your application. Even without compression tricks, users may simply upload enormous files.
Defence: enforce size limits before writing to disk
const multer = require('multer');
const upload = multer({
dest: '/tmp/uploads',
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB hard limit
files: 5 // max 5 files per request
}
});
Multer enforces fileSize while streaming — it rejects the
upload before the full file is written. Handle the error:
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large. Maximum is 10 MB.' });
}
next(err);
});
ClamAV also detects known zip bomb signatures. pompelmi will return
Verdict.Malicious for files matching ClamAV's zip bomb
signatures, providing a second layer of protection.
Threat 4 — Malware in uploaded files
Documents (PDF, DOCX, XLSX), executables (.exe, .dll), scripts (.js, .vbs), and even images can contain malware — viruses, ransomware, trojans, or malicious macros. Users who later download these files from your server can have their machines infected.
Defence: scan every file with ClamAV via pompelmi
npm install pompelmi
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
async function scanUpload(tmpPath) {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
throw Object.assign(new Error('Malware detected.'), { statusCode: 400 });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
throw Object.assign(
new Error('Scan incomplete. Rejected as precaution.'),
{ statusCode: 422 }
);
}
// Verdict.Clean — safe to proceed
}
Safe storage after scanning
Even a clean file should be stored carefully:
- Serve from a separate origin. Serve user-uploaded files from a subdomain or object storage (S3, GCS) — not from the same origin as your web application. This prevents XSS via a malicious SVG or HTML file that passes virus scanning but contains JavaScript.
-
Set
Content-Disposition: attachment. When serving uploads, force download mode instead of inline rendering. This prevents the browser from executing HTML or scripts stored as uploads. -
Strip EXIF metadata from images. Images can contain GPS
coordinates, device identifiers, and other sensitive information your
users may not want to share. Use a library like
sharpto strip metadata before storing.
// Force download for served files
app.get('/uploads/:filename', (req, res) => {
const safeName = path.basename(req.params.filename); // Strip any path components
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
res.sendFile(path.join('/var/app/uploads', safeName));
});
Putting it all together
Combining all defences into a single 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']);
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. Validate actual content type via magic bytes
const type = await fileTypeFromFile(tmpPath);
if (!type || !ALLOWED_MIME.has(type.mime)) {
return res.status(415).json({ error: 'File type not permitted.' });
}
// 2. Antivirus scan
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.' });
}
// 3. Save with a safe, random filename — ignore originalname
const safeName = crypto.randomBytes(16).toString('hex') + '.' + type.ext;
const dest = path.join('/var/app/uploads', safeName);
fs.renameSync(tmpPath, dest);
promoted = true;
res.json({ status: 'ok', id: safeName });
} catch (err) {
res.status(err.statusCode || 500).json({ error: err.message });
} finally {
if (!promoted && fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
// Multer size limit error handler
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large.' });
}
next(err);
});