Why File Uploads Are High Risk
File uploads combine multiple attack surfaces: file content, filename, MIME type, file size, and the storage destination. Each of these can be manipulated independently. A naive upload handler might validate the file extension but trust the MIME type, or validate the MIME type but forget to sanitize the filename.
Unrestricted file upload is listed under OWASP A04:2021 (Insecure Design) and consistently appears in penetration testing findings across web applications of every size and technology stack.
Webshell Uploads
If an attacker can upload a server-side script (.php, .jsp, .aspx, .py) to a location served by the web server, and then request that URL, they have remote code execution. This is the worst case β and it's more common than you'd expect.
<?php system($_GET['cmd']); ?> // Uploaded as "avatar.php" or "photo.jpg.php" // Requested at: https://target.com/uploads/avatar.php?cmd=id // Response: uid=33(www-data) gid=33(www-data)
Common bypasses for file extension checks: double extensions (shell.php.jpg), null byte injection (shell.php%00.jpg), case variations (shell.PHP, shell.pHp), and alternate extensions (.php5, .phtml, .shtml).
MIME Type Bypass
Many developers check the Content-Type header from the upload request. This is trivially bypassable β the browser sends whatever the attacker tells it to. The Content-Type header is user-controlled input; it must not be trusted for security decisions.
import magic # python-magic reads actual file magic bytes # WRONG β trusts user-supplied Content-Type def validate_image_bad(file, content_type: str) -> bool: return content_type in ["image/jpeg", "image/png"] # CORRECT β reads magic bytes from file content def validate_image_good(file_bytes: bytes) -> bool: mime = magic.from_buffer(file_bytes, mime=True) return mime in ["image/jpeg", "image/png", "image/gif", "image/webp"]
Path Traversal via Filename
If a filename submitted by the user is used directly in the storage path, an attacker can write files outside the intended upload directory using path traversal sequences:
../../etc/cron.d/backdoor../templates/index.html(overwrite your app templates).htaccess(override Apache config in upload directory)
import os, uuid # Always generate a new filename β never use the user-supplied name def safe_filename(original_name: str, allowed_ext: set) -> str: ext = os.path.splitext(original_name)[1].lower() if ext not in allowed_ext: raise ValueError(f"Extension {ext} not allowed") # Random UUID β no relationship to original filename return f"{uuid.uuid4().hex}{ext}"
DoS via Large Files and Zip Bombs
Without size limits, an attacker can upload gigabyte files to exhaust disk space or memory. For applications that process file contents (extract archives, parse images), zip bombs and image bombs can expand a 1 KB file to gigabytes in memory.
Enforce limits at the web server layer, not just the application: If the application reads the entire file before checking its size, a large upload can still consume resources. Set client_max_body_size in nginx or LimitRequestBody in Apache before the request reaches your app.
Building a Secure Upload Handler
from fastapi import UploadFile, HTTPException import magic, uuid, os ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp"} MAX_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB UPLOAD_DIR = "/data/uploads" # NOT served by web server async def secure_upload(file: UploadFile): content = await file.read(MAX_SIZE_BYTES + 1) # 1. Enforce size limit if len(content) > MAX_SIZE_BYTES: raise HTTPException(413, "File too large") # 2. Validate MIME from magic bytes β NOT Content-Type header mime = magic.from_buffer(content, mime=True) if mime not in ALLOWED_MIME: raise HTTPException(400, "File type not allowed") # 3. Generate safe filename β ignore user-supplied name entirely ext = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[mime] filename = f"{uuid.uuid4().hex}{ext}" # 4. Store outside web root β serve via signed URLs or a proxy dest = os.path.join(UPLOAD_DIR, filename) with open(dest, "wb") as f: f.write(content) return filename
Secure Storage Architecture
- Never store uploads inside your web root β files stored at
/var/www/html/uploads/can be requested directly. Store outside web root and serve through your application. - Use object storage (S3, GCS, Azure Blob) β separate from your application servers. Even if an attacker uploads a PHP file, it can't execute in S3.
- Serve via CDN with signed URLs β time-limited access tokens prevent direct file enumeration.
- Re-process images with Pillow/ImageMagick β strip EXIF metadata and re-encode to eliminate embedded payloads. A JPEG containing PHP code is still dangerous if your server later parses it.
File Upload Security Checklist
- Validate file type from magic bytes β never trust Content-Type header
- Enforce allowlist of permitted extensions and MIME types
- Generate a random UUID filename β never use user-supplied filename
- Enforce file size limits at web server AND application layer
- Store uploads outside web root or in object storage (S3, GCS)
- Strip EXIF metadata; re-encode images to eliminate embedded payloads
- Disable script execution in the upload directory (nginx
locationblock) - Scan uploaded files with antivirus for malware (especially documents)
Find File Upload Vulnerabilities in Your Code
AquilaX SAST detects missing file type validation, path traversal in filenames, and insecure storage in upload handlers across your codebase.
Start Free Scan