Skip to content
>_ TrueFileSize.com
··10 min read

File Upload Security Checklist — 10 Steps Every Developer Must Follow

File upload is the #1 attack vector in web applications (OWASP Top 10: A04 Insecure Design). A single misconfigured upload endpoint can lead to remote code execution, data breaches, and server takeover. This checklist covers every defense layer.

Real-World Incidents

  • Equifax (2017): Exploited via a file upload vulnerability in Apache Struts — 147 million records leaked
  • ImageTragick (2016): Malicious image files executed shell commands through ImageMagick
  • Zip Slip (2018): Archive extraction vulnerability affected thousands of projects including Apache, AWS, and LinkedIn

The 10-Step Checklist

1. Validate File Type with Magic Bytes (Not Extension)

Extensions can be renamed. MIME types can be spoofed. Magic bytes are reliable.

// Node.js — file-type library
import { fileTypeFromBuffer } from 'file-type';

async function validateFileType(buffer, allowedTypes) {
  const type = await fileTypeFromBuffer(buffer);
  if (!type) throw new Error('Unrecognized file type');
  if (!allowedTypes.includes(type.mime)) {
    throw new Error(`Type ${type.mime} not allowed`);
  }
  return type;
}

// Usage
const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
const type = await validateFileType(fileBuffer, allowed);
# Python — python-magic
import magic

def validate_file_type(filepath, allowed_mimes):
    mime = magic.from_file(filepath, mime=True)
    if mime not in allowed_mimes:
        raise ValueError(f"File type {mime} not allowed")
    return mime

Test with our wrong-extension sample files — JPEG data disguised as .pdf.

2. Whitelist, Never Blacklist

// WRONG — blacklist (attacker finds new extension)
const BLOCKED = ['.exe', '.bat', '.sh', '.php', '.jsp'];

// RIGHT — whitelist (only explicitly safe types)
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'application/pdf'];
const ALLOWED_EXTS = ['.jpg', '.jpeg', '.png', '.pdf'];

3. Limit File Size at Every Layer

# Nginx (first line of defense)
client_max_body_size 10M;
// Express/multer
const upload = multer({
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5,                    // Max 5 files per request
  },
});

Test with exact-size sample files: 1MB, 5MB, 10MB, 50MB, 100MB.

4. Generate Random Filenames

Never use the user-supplied filename in storage paths.

import { randomUUID } from 'crypto';
import path from 'path';

function safeFilename(originalName, detectedMime) {
  const ext = {
    'image/jpeg': '.jpg',
    'image/png': '.png',
    'application/pdf': '.pdf',
  }[detectedMime] || '.bin';

  return randomUUID() + ext;
  // Result: "a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg"
}

5. Sanitize Filenames (If You Must Keep Them)

function sanitizeFilename(name) {
  return name
    .replace(/[^a-zA-Z0-9._-]/g, '_')  // Remove dangerous chars
    .replace(/\.{2,}/g, '.')             // No directory traversal
    .replace(/^\./, '_')                  // No hidden files
    .slice(0, 200);                       // Length limit
}

6. Store Outside Webroot

project/
├── public/          ← webroot (served by Nginx)
│   ├── index.html
│   └── css/
├── uploads/         ← OUTSIDE webroot (not directly accessible)
│   ├── a1b2c3.jpg
│   └── d4e5f6.pdf
└── app/
    └── serve-file.js  ← access-controlled endpoint

7. Scan for Malware

// ClamAV integration
import NodeClam from 'clamscan';

const clam = await new NodeClam().init({
  clamdscan: { host: '127.0.0.1', port: 3310 },
});

const { isInfected, viruses } = await clam.isInfected(filePath);
if (isInfected) {
  fs.unlinkSync(filePath);
  throw new Error(`Malware detected: ${viruses.join(', ')}`);
}

8. Re-process Images (Strip Payloads)

Don't serve user-uploaded images directly. Re-encode them to strip embedded scripts:

import sharp from 'sharp';

// Re-encode image — strips EXIF, embedded scripts, polyglot payloads
await sharp(uploadedPath)
  .resize(2000, 2000, { fit: 'inside', withoutEnlargement: true })
  .jpeg({ quality: 85 })  // Force re-encode
  .toFile(safePath);

fs.unlinkSync(uploadedPath); // Delete original

9. Set Content-Disposition: attachment for Downloads

app.get('/files/:id', authenticate, (req, res) => {
  res.setHeader('Content-Disposition', 'attachment; filename="document.pdf"');
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.sendFile(safePath);
});

10. Rate Limit Upload Endpoints

import rateLimit from 'express-rate-limit';

const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 20,                    // 20 uploads per window
  message: 'Too many uploads, try again later',
});

app.post('/api/upload', uploadLimiter, upload.single('file'), handler);

OWASP References

Test Your Security

Download these to test your upload validation:

| Test | File | What It Tests | |------|------|--------------| | Valid PDF | sample-1mb.pdf | Should pass | | Valid image | sample-500kb.jpg | Should pass | | Wrong extension | sample-jpg-as-pdf.pdf | Should be caught by magic bytes | | Corrupted | sample-corrupt.pdf | Should be caught by re-processing | | Zero byte | sample-zero-byte.bin | Should be rejected | | Oversized | sample-50mb.mp4 | Should hit size limit |