python-magic (Python), file-type npm package (Node.js), http.DetectContentType() (Go) -- validate actual file content, not just headers or extensions.Content-Type header or file extension alone -- both are trivially spoofed; combine with magic byte inspection for real content validation.FileExtensionValidator + python-magic, Express/Multer + file-type, Go net/http.DetectContentType.| # | Vulnerability | Risk | Vulnerable Code | Secure Code |
|---|---|---|---|---|
| 1 | Unrestricted upload (CWE-434) | Critical -- RCE via web shell | No extension/type check | Allowlist extensions + magic byte validation |
| 2 | MIME type spoofing | High -- bypass content filters | if (file.mimetype === 'image/png') | Read first 512+ bytes, validate magic signature |
| 3 | Path traversal via filename | High -- arbitrary file write | path.join(uploadDir, file.originalname) | Generate UUID filename, strip all path components |
| 4 | Stored XSS via SVG/HTML upload | High -- XSS on same origin | Allow SVG/HTML uploads, serve inline | Serve with Content-Disposition: attachment + CSP |
| 5 | Malware upload | High -- infect other users | No scanning | Scan with ClamAV or cloud AV API before storage |
| 6 | Zip bomb / decompression bomb | Medium -- DoS, disk exhaustion | Extract without size checks | Enforce compression ratio < 100:1, max extracted size |
| 7 | Image bomb (pixel flood) | Medium -- DoS, memory exhaustion | Process image without dimension check | Enforce max pixel dimensions (e.g., 10000x10000) |
| 8 | Double extension bypass | High -- execute disguised file | Block .php but allow file.php.jpg | Check final extension only after stripping all dots |
| 9 | Null byte injection | High -- truncate filename | file.php%00.jpg passes extension check | Reject filenames containing null bytes or non-printable chars |
| 10 | Oversized upload DoS | Medium -- disk/bandwidth exhaustion | No file size limit | Enforce max size at reverse proxy AND application level |
START: What type of file upload does your application accept?
├── Images only (JPEG, PNG, GIF, WebP)?
│ ├── YES → Allowlist image extensions + validate magic bytes + re-encode image (strip metadata)
│ └── NO ↓
├── Documents (PDF, DOCX, XLSX)?
│ ├── YES → Allowlist doc extensions + magic bytes + Content Disarm & Reconstruct (CDR) or AV scan
│ └── NO ↓
├── Archives (ZIP, TAR, GZ)?
│ ├── YES → Allowlist archive extensions + magic bytes + decompression ratio < 100:1 + max extracted size
│ └── NO ↓
├── Executable or script files needed?
│ ├── YES → STOP: reconsider architecture. If unavoidable: sandbox + AV scan + never serve from web root
│ └── NO ↓
├── Mixed/unknown file types?
│ ├── YES → Allowlist only required extensions + magic bytes + AV scan + Content-Disposition: attachment
│ └── NO ↓
└── DEFAULT → Allowlist extensions + magic byte validation + UUID filename + store outside web root
Set maximum upload size at both the reverse proxy and application level. This is your first line of defense against DoS via oversized uploads. [src1]
# Nginx: limit upload size to 10MB
client_max_body_size 10m;
// Express/Multer: limit to 10MB
const upload = multer({
limits: { fileSize: 10 * 1024 * 1024 }
});
Verify: Upload a file larger than the limit -- should receive HTTP 413 or framework-specific rejection.
Only permit file extensions that your application specifically needs. Use an allowlist, never a blocklist. [src1]
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf'}
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValidationError(f'File type {ext} not allowed')
Verify: Attempt to upload a .php, .exe, or .html file -- should be rejected.
The Content-Type header and file extension are user-controlled. Read the actual file bytes to verify content type. [src2]
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ALLOWED_MIMES:
raise ValidationError(f'File content type {mime} not allowed')
Verify: Rename a .php file to .jpg and upload -- magic byte check should reject it.
Never use the user-supplied filename for storage. Generate a UUID and store files outside the web-accessible directory. [src1]
import uuid, os
safe_name = f'{uuid.uuid4().hex}{validated_ext}'
dest = os.path.join('/var/uploads', safe_name) # Outside web root
Verify: Check that stored files have UUID names, reside outside the web root, and the directory has no execute permission.
Never serve uploaded files directly from the filesystem. Use a controller that sets security headers. [src3]
response['Content-Disposition'] = f'attachment; filename="{original_name}"'
response['X-Content-Type-Options'] = 'nosniff'
response['Content-Security-Policy'] = "default-src 'none'"
Verify: Access an uploaded file URL -- should download (not render inline) with correct security headers.
For untrusted user uploads, integrate virus scanning before persistence. [src5]
clamscan --max-filesize=10M uploaded_file.pdf
Verify: Upload an EICAR test file -- should be detected and rejected.
import os, uuid, magic
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.http import require_POST
ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']
ALLOWED_MIMES = {
'image/jpeg', 'image/png', 'image/gif',
'image/webp', 'application/pdf'
}
MAX_SIZE = 10 * 1024 * 1024 # 10MB
UPLOAD_DIR = '/var/uploads' # Outside web root
@require_POST
def upload(request):
file = request.FILES.get('file')
if not file:
return JsonResponse({'error': 'No file'}, status=400)
if file.size > MAX_SIZE:
return JsonResponse({'error': 'File too large'}, status=413)
ext = os.path.splitext(file.name)[1].lower()
if ext.lstrip('.') not in ALLOWED_EXT:
return JsonResponse({'error': 'Type not allowed'}, status=400)
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ALLOWED_MIMES:
return JsonResponse({'error': f'Invalid content: {mime}'}, status=400)
safe_name = f'{uuid.uuid4().hex}{ext}'
dest = os.path.join(UPLOAD_DIR, safe_name)
with open(dest, 'wb') as f:
for chunk in file.chunks():
f.write(chunk)
return JsonResponse({'id': safe_name}, status=201)
import express from 'express';
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
import { randomUUID } from 'crypto';
import { readFile, unlink, rename } from 'fs/promises';
import path from 'path';
const UPLOAD_DIR = '/var/uploads';
const ALLOWED_MIMES = new Set([
'image/jpeg', 'image/png', 'image/gif',
'image/webp', 'application/pdf'
]);
const upload = multer({
dest: '/tmp/uploads',
limits: { fileSize: 10 * 1024 * 1024 }
});
const app = express();
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
try {
const buffer = await readFile(req.file.path);
const type = await fileTypeFromBuffer(buffer);
if (!type || !ALLOWED_MIMES.has(type.mime)) {
await unlink(req.file.path);
return res.status(400).json({ error: 'Invalid file content' });
}
const safeName = `${randomUUID()}.${type.ext}`;
await rename(req.file.path, path.join(UPLOAD_DIR, safeName));
res.status(201).json({ id: safeName });
} catch (err) {
if (req.file?.path) await unlink(req.file.path).catch(() => {});
res.status(500).json({ error: 'Upload failed' });
}
});
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
const (
maxUploadSize = 10 << 20 // 10MB
uploadDir = "/var/uploads"
)
var allowedMIMEs = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"application/pdf": ".pdf",
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file", http.StatusBadRequest)
return
}
defer file.Close()
buf := make([]byte, 512)
n, _ := file.Read(buf)
mime := http.DetectContentType(buf[:n])
ext, ok := allowedMIMEs[mime]
if !ok {
http.Error(w, fmt.Sprintf("Type %s not allowed", mime), http.StatusBadRequest)
return
}
file.Seek(0, io.SeekStart)
randBytes := make([]byte, 16)
rand.Read(randBytes)
safeName := hex.EncodeToString(randBytes) + ext
dst, err := os.Create(filepath.Join(uploadDir, safeName))
if err != nil {
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
defer dst.Close()
io.Copy(dst, file)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": safeName})
}
# BAD -- extension is user-controlled and trivially spoofed
def upload(request):
file = request.FILES['file']
if file.name.endswith('.jpg'): # Attacker renames malware.php to malware.jpg
file.save('/var/www/uploads/' + file.name) # Stored in web root!
# GOOD -- multi-layer validation with safe storage
ext = os.path.splitext(file.name)[1].lower()
if ext not in ALLOWED_EXT:
raise ValidationError('Extension not allowed')
mime = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime not in ALLOWED_MIMES:
raise ValidationError('Content type mismatch')
safe_name = f'{uuid.uuid4().hex}{ext}'
dest = os.path.join('/var/uploads', safe_name)
// BAD -- files in web root can be executed by the web server
const upload = multer({ dest: 'public/uploads/' });
// Attacker uploads shell.php -> accessible at https://example.com/uploads/shell.php
// GOOD -- files stored outside web root, served through authenticated route
const upload = multer({ dest: '/var/uploads/' });
app.get('/files/:id', authenticate, (req, res) => {
res.set('Content-Disposition', 'attachment');
res.set('X-Content-Type-Options', 'nosniff');
res.sendFile(path.join('/var/uploads', req.params.id));
});
// BAD -- no size limit allows DoS via multi-GB uploads
const upload = multer({ dest: '/tmp/uploads' });
app.post('/upload', upload.single('file'), handler);
// GOOD -- size limit at Multer + Nginx/reverse proxy
const upload = multer({
dest: '/tmp/uploads',
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
// Also set in Nginx: client_max_body_size 10m;
// BAD -- path traversal: filename could be "../../etc/passwd"
destPath := filepath.Join(uploadDir, header.Filename)
dst, _ := os.Create(destPath)
// GOOD -- UUID filename prevents path traversal and collisions
randBytes := make([]byte, 16)
rand.Read(randBytes)
safeName := hex.EncodeToString(randBytes) + validatedExt
destPath := filepath.Join(uploadDir, safeName)
file.mimetype comes from the client-sent header, which attackers control. Fix: Use file-type or python-magic to read actual file bytes. [src4]malware.php.jpg may pass extension checks but execute as PHP on misconfigured servers. Fix: Check only the final extension; configure web server to not execute files in upload directories. [src1]malware.php%00.jpg truncates at the null byte on some systems. Fix: Reject filenames containing null bytes or non-printable characters. [src2]<script> tags; HTML files execute JavaScript if served inline. Fix: Serve with Content-Disposition: attachment and restrictive CSP. [src3]../../etc/cron.d/backdoor writes outside intended directory. Fix: Generate UUID filenames; never use user input in file paths. [src6]# Check file's real MIME type via magic bytes
file --mime-type uploaded_file.jpg
# Check file magic signature (first 16 bytes as hex)
xxd -l 16 uploaded_file.jpg
# Known magic bytes: JPEG=FF D8 FF, PNG=89 50 4E 47, PDF=%PDF, ZIP=50 4B 03 04
# Scan file for malware with ClamAV
clamscan uploaded_file.pdf
# Check upload directory permissions (should NOT have execute)
ls -la /var/uploads/
# Check Nginx upload size limit
nginx -T 2>/dev/null | grep client_max_body_size
# Find files with double extensions in upload directory
find /var/uploads -name '*.*.*' -type f
# Check for executable files in upload directory
find /var/uploads -type f -executable
| Standard/Tool | Version | Status | Key Feature |
|---|---|---|---|
| OWASP File Upload Cheat Sheet | 2024 | Current | Comprehensive upload security guidance |
| CWE-434 | 4.19 | Current | Top-25 CWE weakness classification |
| python-magic | 0.4.x | Current | libmagic bindings for Python |
| file-type (npm) | 19.x | Current | Magic byte detection (ESM only) |
| file-type (npm) | 16.x | Maintained | CommonJS support (legacy) |
| multer (npm) | 1.4.x | Current | Express multipart middleware |
| Go net/http | 1.22+ | Current | DetectContentType() built-in (512 bytes) |
| gabriel-vasile/mimetype | 1.4.x | Current | Extended magic byte detection for Go |
| ClamAV | 1.3.x | Current | Open-source antivirus scanning |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Application accepts file uploads from any user | Application only stores files generated server-side | Standard filesystem security |
| Uploaded files are served back to other users | Files are only processed server-side and never served | Simpler validation may suffice |
| Upload includes images, documents, or archives | API only accepts JSON/text payloads (no binary files) | Input validation patterns |
| Running a multi-tenant platform with untrusted users | Single-user admin tool with trusted operators | Reduced validation + logging |
| Files are processed (image resize, document parse) | Files are stored as opaque blobs and never opened | Hash verification + size limits only |
http.DetectContentType() in Go only checks the first 512 bytes and supports a limited set of MIME types -- for broader detection use gabriel-vasile/mimetype or h2non/filetypepython-magic requires libmagic system library (apt install libmagic1 on Debian/Ubuntu, brew install libmagic on macOS) -- without it, the library fails silently or crashesfileFilter runs before the file is fully written to disk -- magic byte validation must happen after the upload completes on the temp fileclamd daemon mode with Unix socket, not the clamscan CLI