The File Upload That Becomes a Shell
Validating the mime type does not tell you what a file is, and storing uploads under the document root means the server will happily execute whatever was validated incorrectly.
An upload form is a route that lets an anonymous person put a file on your server. Most of the ways that goes wrong come from two decisions made in the first hour of building it.
What validation actually tells you
$request->validate([
'avatar' => 'required|file|mimes:jpg,png|max:2048',
]);This is reasonable and people over-trust it. mimes inspects the file's
contents rather than its name, which is a real improvement over checking the
extension - but "starts with a valid PNG header" and "is only a PNG" are not
the same statement. A file can carry a legitimate image header and PHP source
after it, and still satisfy the rule.
What makes that harmless is not better validation. It is that nothing can execute the file.
Two rules that are free and worth having anyway:
'avatar' => 'required|image|dimensions:max_width=4000,max_height=4000|max:2048',image is stricter than mimes for image uploads, and dimensions refuses
the decompression bomb - a small file that expands to something that exhausts
memory when your thumbnailer opens it.
Never trust getClientOriginalName() or getClientMimeType() for anything.
Both are supplied by the client. The original name is fine to store as a
display label; it is not fine to build a path from.
The decision that actually matters: where it lands
// wrong for user uploads
$request->file('avatar')->store('avatars', 'public');The public disk is a symlink into your web root. Anything there is served
directly by the web server, and if the server is configured to execute PHP in
that directory - which plenty are, by default or by accident - a file that got
through validation is now a URL that runs code.
Store uploads on a private disk:
$path = $request->file('avatar')->store('avatars'); // private by defaultand serve them through a route that checks who is asking:
public function show(Attachment $attachment)
{
$this->authorize('view', $attachment);
return Storage::download($attachment->path, $attachment->original_name);
}That route costs a little performance and buys two things at once: nothing is executable, and access is authorised. Object storage with signed, expiring URLs is the same arrangement with the bandwidth moved off your server.
Names, paths and the directory above
Let the framework generate the stored name. store() produces a random name
already, which removes an entire category of problem: path traversal from a
crafted filename, files overwriting each other, and names that mean something
unfortunate to a shell.
Keep the user's original name in a database column for display. The two are different things and conflating them is where traversal bugs come from.
Serving is where the remaining risk is
SVG is not an image. It is an XML document that can contain script, and a browser executes that script in the origin it was served from. If you accept SVG, either sanitise it with a library built for that, or serve user files from a separate domain so any script that survives runs somewhere it cannot reach your session cookie.
Set the content type yourself. Serve a stored type you decided, not one derived from the file at download time.
Force a download where you can. Content-Disposition: attachment means the
browser saves rather than renders, which neutralises most of what a hostile
file could do in a page.
Strip metadata from images. Photographs carry EXIF, and EXIF carries GPS coordinates. Publishing a user's uploaded photo unprocessed can publish where they took it.
The limits nobody sets until something breaks
A size limit in validation is not a limit on what reaches your server - PHP's
upload_max_filesize and post_max_size decide that, and the web server has
its own before PHP sees anything. Set all three, deliberately, and make sure
the error a user gets when they exceed them is a message rather than a blank
page.
Rate-limit the upload route. An endpoint that accepts files without a limit is a way to fill your disk from outside, and a full disk takes down everything else with it.
Scanning, when you are a distribution channel
If users upload files that other users download, the risk is no longer only yours. Scan asynchronously - a queued job after the upload, with the file marked unavailable until it clears - so a slow scan does not sit inside the request.
The short version
Validate with image and dimensions, store privately with a generated name,
serve through an authorised route with a content type you chose, and never let
uploads land anywhere the web server is willing to execute.
Get the storage location right and the validation becomes a convenience rather than the thing standing between you and a shell.
The route that serves the file needs the other half of this: an authorisation check, not an unguessable filename. Both sit on the list an audit works through. Neither turns up in a code review, because nothing is wrong with the code that is there.
