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.

This article focuses on the defence layer inside your Node.js server. For a comprehensive checklist see the Node.js file upload security checklist.

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' }
}
Magic byte validation and virus scanning are complementary, not alternatives. A Word document containing a macro virus will pass magic byte validation (it is a valid DOCX) but ClamAV will flag the embedded macro. You need both.

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
}
For a step-by-step guide to integrating pompelmi into your project see Getting started with antivirus scanning in Node.js.

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 sharp to 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);
});