File Upload Security: Preventing Unrestricted File Upload Attacks
How do I implement secure file uploads?
TL;DR
- Bottom line: Defense-in-depth with extension allowlisting, magic byte validation, random filename generation, and storage outside the web root prevents CWE-434 (unrestricted file upload) -- no single check is sufficient.
- Key tool/command:
python-magic(Python),file-typenpm package (Node.js),http.DetectContentType()(Go) -- validate actual file content, not just headers or extensions. - Watch out for: Trusting the
Content-Typeheader or file extension alone -- both are trivially spoofed; combine with magic byte inspection for real content validation. - Works with: All web frameworks. OWASP File Upload Cheat Sheet applies universally. Framework-specific: Django
FileExtensionValidator+python-magic, Express/Multer +file-type, Gonet/http.DetectContentType.
Constraints
- NEVER trust the Content-Type header or file extension alone -- both are trivially spoofed by attackers
- NEVER store uploaded files inside the web root or any directory with execute permissions
- ALWAYS validate file content using magic bytes (file signature) in addition to extension allowlists
- ALWAYS generate random filenames (UUID) server-side -- never use the original user-supplied filename for storage
- ALWAYS enforce server-side file size limits -- client-side limits are trivially bypassed
- NEVER process uploaded archives (ZIP, TAR) without decompression ratio limits to prevent zip bombs
Quick Reference
| # | 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 |
Decision Tree
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
Step-by-Step Guide
1. Configure server-side file size limits
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.
2. Implement extension allowlisting
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.
3. Validate file content with magic bytes
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.
4. Generate random filenames and store outside web root
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.
5. Serve uploaded files safely
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.
6. Scan for malware (production environments)
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.
Code Examples
Python/Django: Complete Secure Upload Handler
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)
Node.js/Express: Multer with Magic Byte Validation
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' });
}
});
Go: Secure File Upload Handler
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})
}
Anti-Patterns
Wrong: Trusting file extension only
# 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!
Correct: Extension allowlist + magic bytes + UUID filename
# 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)
Wrong: Storing uploads in the web root
// 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
Correct: Store outside web root, serve via controller
// 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));
});
Wrong: No file size limits
// BAD -- no size limit allows DoS via multi-GB uploads
const upload = multer({ dest: '/tmp/uploads' });
app.post('/upload', upload.single('file'), handler);
Correct: Enforce size limits at multiple layers
// 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;
Wrong: Using user-supplied filename for storage
// BAD -- path traversal: filename could be "../../etc/passwd"
destPath := filepath.Join(uploadDir, header.Filename)
dst, _ := os.Create(destPath)
Correct: Generate random filename, ignore user input
// 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)
Common Pitfalls
- MIME spoofing via Content-Type header: Multer's
file.mimetypecomes from the client-sent header, which attackers control. Fix: Usefile-typeorpython-magicto read actual file bytes. [src4] - Double extension bypass:
malware.php.jpgmay 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] - Null byte injection:
malware.php%00.jpgtruncates at the null byte on some systems. Fix: Reject filenames containing null bytes or non-printable characters. [src2] - SVG/HTML files enabling XSS: SVG files can contain
<script>tags; HTML files execute JavaScript if served inline. Fix: Serve withContent-Disposition: attachmentand restrictive CSP. [src3] - Zip bomb (decompression bomb): A 42KB ZIP can expand to 4.5PB. Fix: Enforce decompression ratio limit (< 100:1), maximum extracted file count, and maximum total extracted size. [src5]
- Image bomb (pixel flood): A small file with declared dimensions of 100000x100000 pixels causes OOM when processed. Fix: Read image headers (dimensions) before processing; enforce max 10000x10000 pixels. [src5]
- Path traversal via filename: Filename
../../etc/cron.d/backdoorwrites outside intended directory. Fix: Generate UUID filenames; never use user input in file paths. [src6] - Race condition (TOCTOU): File is validated, then replaced before it is moved to permanent storage. Fix: Validate on the already-saved temp file; use atomic move operations. [src1]
Diagnostic Commands
# 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
Version History & Compatibility
| 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
http.DetectContentType()in Go only checks the first 512 bytes and supports a limited set of MIME types -- for broader detection usegabriel-vasile/mimetypeorh2non/filetypepython-magicrequireslibmagicsystem library (apt install libmagic1on Debian/Ubuntu,brew install libmagicon macOS) -- without it, the library fails silently or crashes- Multer's
fileFilterruns before the file is fully written to disk -- magic byte validation must happen after the upload completes on the temp file - ClamAV scanning adds 100-500ms per file; for high-throughput systems, use
clamddaemon mode with Unix socket, not theclamscanCLI - Image re-encoding (to strip metadata/payloads) may alter image quality -- always use lossless re-encoding for PNG/GIF and configurable quality for JPEG
- Cloud storage (S3, GCS) with signed URLs shifts some security to the storage layer, but client-side validation and magic byte checks are still required before upload