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
- OWASP File Upload Cheat Sheet
- OWASP Testing Guide: File Upload
- A04:2021 Insecure Design, A03:2021 Injection
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 |