A file upload looks like one of the simplest features you'll ever build, and it's one of the most dangerous. You're letting a stranger put a file of their choosing onto your server, and depending on what you do next, that single feature can hand them remote code execution, store an XSS payload for every other user, let them write outside the upload directory, or simply exhaust your disk.
The root cause is almost always the same: trusting the file to be what it says it is.
The risks, concretely
- Remote code execution. A
.phpor.jspweb shell that the server happily executes. - Stored XSS. An HTML or SVG file with embedded JavaScript, served back to other users.
- Path traversal. A filename like
../../etc/passwdthat writes somewhere it shouldn't. - Denial of service. Enormous files, or zip bombs that explode on extraction.
- Malware hosting. Your server quietly distributing someone else's payload.
Never trust the metadata
The filename, the MIME type, and the extension are all set by the client, which means they're all attacker-controlled. The only thing you can trust is the actual content of the file, so validate that.
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
if mime not in ["image/jpeg", "image/png"]:
raise ValueError("Invalid file type")
Then add the other guardrails:
Size limits at the web server, the application, and the storage quota.
Re-encode images, which strips embedded scripts, EXIF data, and malformed content in one move:
from PIL import Image
img = Image.open(uploaded_file)
img.save(output, format="PNG")
Generate your own filenames rather than sanitising theirs:
safe_filename = f"{uuid.uuid4()}.png"
Where you put the file matters
Store outside the web root. A file inside the document root can be executed by the server. Drop a .php into a directory Apache serves and Apache will run it.
Prefer object storage. S3 or GCS keeps uploads off your application server entirely, so there's nothing to execute, and you get access control for free. Serve them through pre-signed URLs with a short expiry.
Serving it back safely
- Set an explicit
Content-Typefrom the type you validated, not from what the user claimed. - Force a download with
Content-Disposition: attachmentwhere it makes sense. - Serve uploads from a separate domain like
uploads.example.com, so that an injected script lands on a different origin and can't touch your main app. - Add
X-Content-Type-Options: nosniffso the browser doesn't second-guess your content type.
The high-risk file types
| Type | Risk | What to do |
|---|---|---|
| SVG | Embedded JavaScript | Sanitise, or reject |
| HTML | Script execution | Serve as attachment, never text/html |
| ZIP | Bombs, path traversal | Cap size, sanitise paths on extraction |
| Office | Macros | Scan, or convert to PDF |
Validate the bytes, not the label. Re-encode what you can. Store it outside the web root, and serve it from somewhere that can't hurt you.
