Skip to content

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.

5 min read

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 default

and 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.

Related questions

Is the mimes validation rule enough?
It is better than checking the extension, because it inspects the file rather than the name. It is still not sufficient on its own - a file can carry a valid image header and PHP after it. The rule that actually stops execution is storing uploads where nothing will execute them.
Where should uploads be stored?
Outside the document root, or in object storage, and served through a route that checks authorisation. The public disk is a symlink into the web root, which is correct for assets you intend to be public and wrong for anything a user uploaded.
What about SVG files?
SVG is a document, not an image - it can contain script, and a browser will run it when the file is served from your domain. Either refuse SVG uploads, or sanitise them with a dedicated library, or serve them from a separate domain so any script runs outside your origin.
Do we need antivirus scanning?
Where users share files with each other, yes - you are a distribution channel at that point and the risk transfers to the people downloading. Scan asynchronously after the upload and mark files as unavailable until they clear.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page