Secure File Upload APIs
# CHAPTER 14
Secure File Upload APIs
1. Why upload endpoints are different
Every other endpoint in your API accepts structured data you control the shape of — a JSON body, a query string, a form field. An upload endpoint accepts an arbitrary sequence of bytes chosen entirely by the caller, writes it to your infrastructure, and often hands it back out over HTTP later.
That combination is what makes uploads disproportionately dangerous. A SQL injection flaw leaks data. An upload flaw can put an attacker-authored file on your server and then execute it — which is the difference between a breach and a total compromise.
The rules below are ordered roughly by how much they buy you.
2. Never trust anything the client tells you
Three pieces of information arrive with an upload, and all three are attacker-controlled:
- the filename
-
the
Content-Typeheader
- the file extension
None of them are evidence of anything. A client can declare image/png while sending a PHP script. Validation that reads only the declared type is decorative.
Detect the type from the file contents instead — the leading bytes, often called the magic number:
Note that the extension is taken *from* the detected type, not from what the user sent.
3. Allowlist, never denylist
A denylist tries to enumerate what is dangerous. It always loses, because it has to be exhaustive and the platform keeps adding executable extensions — .php, .php5, .phtml, .phar, .cgi, .jsp, .asp. Miss one and the control is gone.
An allowlist enumerates what is permitted. It fails closed: an unknown type is rejected by default. If the product accepts profile photos, the allowlist is three image types, and everything else on earth is denied without you having to think about it.
4. Generate your own filename
Never store a file under the name the user supplied. It invites path traversal (../../config.php), overwriting existing files, and encoding tricks. Generate a name and keep the original only as a display label in the database:
The stored name is now unguessable, collision-free, and contains no attacker input at all.
5. Store files where they cannot execute
Even a perfectly validated file should not sit somewhere the web server is willing to run. Two good options:
- Outside the document root, served back through a PHP endpoint that checks authorisation and streams the bytes.
- Object storage such as S3 or a compatible service, which has no PHP interpreter to abuse.
If files must live under the web root, execution has to be disabled for that directory at the server level. On Apache:
Treat that as a fallback rather than the primary defence — it is one misplaced config change away from being undone.
6. Enforce size limits in more than one place
Application-level checks run only after the request has been received, so the web server needs its own ceiling too. In PHP that means upload_max_filesize and post_max_size; in nginx, client_max_body_size.
Size limits also matter for archives and images. A compressed file of a few hundred kilobytes can expand to gigabytes — a decompression bomb. If you unpack archives or resize images, cap the *output* dimensions and the extracted size, not just the upload.
7. Handing files back out
Retrieval deserves as much care as the upload:
- Serve user content from a separate domain where practical, so a malicious file cannot reach cookies scoped to your main origin.
-
Set
Content-Disposition: attachmentfor anything not meant to be rendered inline.
-
Send
X-Content-Type-Options: nosniffso browsers respect the declared type instead of guessing.
- Apply the same authorisation on download as on upload. An unguessable filename is not access control — it is obscurity, and it leaks through logs, referrers and shared links.
8. Scan, and rate limit
If uploaded files are shared between users, run them through a malware scanner such as ClamAV before they become downloadable. You are otherwise a convenient distribution point.
Uploads are also expensive — bandwidth, storage, CPU for processing — which makes them an attractive denial-of-service target. The throttling from Chapter 13 applies here, ideally with a stricter budget than your read endpoints.
9. A working order of operations
- 1. Authenticate and authorise the caller.
- 2. Apply rate limiting.
- 3. Reject anything over the size limit.
- 4. Detect the real type from the contents.
- 5. Check it against the allowlist.
- 6. Generate a fresh filename.
- 7. Write outside the web root or to object storage.
- 8. Scan for malware if the file will be shared.
- 9. Record the original name, owner and stored path in the database.
- 10. Serve it back only through an authorised, non-executing path.
10. Practice exercises
- 1. Build an upload endpoint that accepts only PNG and JPEG, verified by magic bytes.
-
2.
Try to defeat your own endpoint: rename a text file to
.png, then send a PHP script with a forgedContent-Type: image/png. Both must be rejected.
- 3. Add a download route that checks ownership before streaming a file, and confirm one user cannot fetch another user's upload by guessing the path.
- 4. Set a 2 MB limit at both the application and web-server level, and observe how the error differs depending on which one rejects the request.
11. Summary
Upload endpoints fail when they trust the caller's description of the file instead of examining it. Verify the contents, choose the name yourself, store it somewhere inert, and authorise every retrieval. Chapter 15 turns to error handling — including how a careless upload error message can hand an attacker your directory structure.
The offensive counterpart to this chapter, covering how these filters are bypassed in practice, is *File Upload Security* in the Web Application Vulnerabilities tutorial.