Understanding how to attack file uploads is only half the equation. As a security professional, you must also be able to recommend and implement effective defenses. In this lesson, we will cover a comprehensive defense-in-depth strategy for file upload security, building on all the attack techniques we have learned in previous lessons.
The first line of defense is rigorous input validation. Use a whitelist approach — explicitly define what is allowed rather than trying to block what is dangerous. Validate every aspect of the uploaded file:
import os
import magic
from pathlib import Path
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif'}
ALLOWED_MIME_TYPES = {'image/jpeg', 'image/png', 'image/gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def validate_upload(file) -> tuple[bool, str]:
# 1. Check file size
file.seek(0, 2) # Seek to end
size = file.tell()
file.seek(0) # Reset
if size > MAX_FILE_SIZE:
return False, "File exceeds maximum size"
if size == 0:
return False, "File is empty"
# 2. Check extension
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
return False, f"Extension '{ext}' not allowed"
# 3. Check MIME type from headers
if file.content_type not in ALLOWED_MIME_TYPES:
return False, f"Content-Type '{file.content_type}' not allowed"
# 4. Check magic bytes (actual file content)
header = file.read(1024)
file.seek(0)
detected = magic.from_buffer(header, mime=True)
if detected not in ALLOWED_MIME_TYPES:
return False, f"Detected type '{detected}' does not match allowed types"
return True, "Validation passed"Where and how you store uploaded files is just as important as validating them. Follow these principles:
import uuid
import os
UPLOAD_DIR = "/var/app/uploads/" # Outside web root
SERVE_URL = "https://static.example.com/"
def save_upload(file) -> str:
# Generate random filename — discard original name entirely
ext = Path(file.filename).suffix.lower()
safe_name = f"{uuid.uuid4().hex}{ext}"
save_path = os.path.join(UPLOAD_DIR, safe_name)
# Save file
file.save(save_path)
# Set permissions — no execute
os.chmod(save_path, 0o644)
return safe_name
# Serve files through a controller that sets proper headers
@app.route('/files/<filename>')
def serve_file(filename):
# Validate filename (alphanumeric + extension only)
if not re.match(r'^[a-f0-9]{32}\.(jpg|jpeg|png|gif)$', filename):
abort(404)
response = send_from_directory(UPLOAD_DIR, filename)
response.headers['Content-Type'] = 'image/jpeg' # Force correct type
response.headers['Content-Disposition'] = 'inline'
response.headers['X-Content-Type-Options'] = 'nosniff'
return responseFor image uploads, recompile the image using a trusted library. This strips any embedded code, metadata, or polyglot content. The recompiled image is a fresh, clean image that contains only pixel data.
from PIL import Image
import io
def recompile_image(file_path: str, output_path: str) -> bool:
try:
with Image.open(file_path) as img:
# Verify it's a valid image
img.verify()
# Re-open (verify closes the file) and re-save
with Image.open(file_path) as img:
# Convert to RGB to strip alpha channel and metadata
rgb_img = img.convert('RGB')
# Save as new JPEG — this creates a clean file
rgb_img.save(output_path, 'JPEG', quality=85)
return True
except Exception as e:
# If recompilation fails, the file is not a valid image
print(f"Image recompilation failed: {e}")
return FalseConfigure your web server to prevent execution of scripts in upload directories. This is a critical safety net — even if an attacker uploads a script, the server should refuse to execute it.
# Apache: Disable PHP execution in upload directory
<Directory "/var/www/html/uploads">
php_flag engine off
<FilesMatch "\.(php|phtml|php3|php4|php5|phar|pl|py|jsp|asp|aspx|cgi|sh|bash)$">
Require all denied
</FilesMatch>
</Directory># Nginx: Deny execution of scripts in upload location
location /uploads/ {
# Disable PHP execution
location ~ \.php$ {
return 403;
}
# Set proper headers
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition "attachment";
# Only serve specific file types
location ~ \.(jpg|jpeg|png|gif|pdf)$ {
try_files $uri =404;
}
}Implement Content Security Policy headers to limit the impact of any uploaded content that might be served to users:
# Set security headers on all responses
@app.after_request
def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"img-src 'self' https://static.example.com; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'"
)
response.headers['X-Frame-Options'] = 'DENY'
return response| Layer | Control | Prevents |
|---|---|---|
| 1. Input Validation | Whitelist extension, MIME, magic bytes, size | Upload of malicious file types |
| 2. Secure Storage | Random filename, outside web root, no execute | Direct access and execution |
| 3. Image Recompilation | Re-encode images with trusted library | Embedded code in images |
| 4. Web Server Config | Disable script execution in upload dir | Execution of uploaded scripts |
| 5. Security Headers | CSP, X-Content-Type-Options, nosniff | XSS, MIME confusion |
| 6. Monitoring | Log uploads, alert on anomalies | Delayed detection of attacks |
No single defense is sufficient. An attacker who bypasses validation might still be stopped by the web server configuration. If they bypass that, the security headers prevent the malicious content from executing in users' browsers. Defense in depth means that each layer compensates for the potential failure of the others.
Verify exercises to earn ★ 160 XP and unlock next lab level.