Ahosting Logo
Knowledge Base

How to Set Up a File Upload Directory Safely

One rule matters more than all the othersThe rule· nothing in the upload directory may ever be executed· a script that reaches it must be inert· block execution at the server, not in the applicationThe supporting measures· validate the file type by content, not by extension· rename what is stored· cap the size· serve from outside the web root if you canWhy the rule is the one that mattersEverything else limits what gets in. Blocking execution decides whether getting in is worthanything.

Any site with a contact form that takes attachments, a profile picture, or a document submission has a directory that strangers write files into. How that directory is configured decides whether that is a feature or a way into the server.

Nothing in it may be executed

This is the whole subject in one sentence.

If the server will run a file placed in the upload directory, then anyone who can upload a file can run code on your server. That is the most reliably exploited weakness in sites that accept uploads, and it does not require any clever technique, only a directory that executes.

There are two ways to prevent it, and the first is better.

Best: keep uploads outside the web root

Store them above public_html, where the web server will not serve them at all, and deliver them through your own code:

/home/username/uploads/ ← files land here
/home/username/public_html/ ← the website

Your application reads the file and sends it, which means you decide who may see it and what content type it is served as. Nothing in that directory is reachable by URL, whatever it is named.

This also solves access control, which the alternative does not, files in a public directory are public to anyone who knows the address, and addresses leak.

Otherwise: disable execution explicitly

When the application insists on a public directory, refuse execution there:

<FilesMatch "\.(php|phtml|php[0-9]|pl|py|cgi|sh)$">
 Require all denied
</FilesMatch>
Options -ExecCGI
php_flag engine off

Place that in an .htaccess inside the upload directory. Then test it, put a harmless PHP file there yourself, request it, and confirm you get a refusal rather than output.

An untested rule is a belief. .htaccess rules worth knowing goes over the file, and note the rule has to survive the application rewriting the directory, which some do.

The name and the type are chosen by the uploader

Neither the file extension nor the type the browser reported is evidence of anything. Both come from the person uploading.

Determine the real type by inspecting the file, and compare it against a list of what you accept. An allow list, not a block list. Block lists are always missing something.

Then rename the file to something you generated. This removes several problems at once: names designed to escape the directory, names with characters that confuse other tools, and collisions between two users uploading the same filename.

Keep the original name in the database as a label if you need to show it.

Bound the size and the count

An upload path with no limit is a way to fill the hosting account.

That matters more than it sounds, because a full account cannot write: mail is refused, database writes fail, sessions break, and the errors mention none of it. Monitoring your hosting resources deals with recognising it.

Limit the file size in the application as well as in PHP, and limit how many uploads one visitor can make in a period. The PHP settings are a backstop, not the policy. There is more on the two values that must move together in configuring PHP settings in cPanel.

Images deserve one extra step

An image can carry data that is not an image, including metadata and, in some formats, embedded content.

Re-encoding the image on receipt, reading it and writing a new file: discards everything that was not picture data. It also strips the metadata, which is worth doing anyway: uploaded photographs routinely carry device models, timestamps and locations that you would then be republishing.

For video the equivalent step is conversion, and it needs its own precautions. See ffmpeg-hosting/how-to-process-untrusted-video-uploads-safely.html">processing untrusted video uploads safely.

Permissions

Directories 755, files 644. The web server needs to write, and that comes from ownership rather than from loosening permissions.

Never 777. It appears in forum advice as a fix for upload problems and it makes the file writable by anyone on the server. Understanding file permissions and ownership walks through what a permissions error usually really is.

Check what is in there

find ~/public_html/uploads -type f -name "*.ph*" -o -name "*.cgi" | head

On an existing site, run that before assuming anything. Finding a PHP file in an upload directory is not ambiguous, and it means the investigation starts now. Cleaning up a hacked site goes into what follows.

Serve uploads through your own code

Keeping files outside the web root only helps if the delivery path is deliberate. The shape worth using:

$file = '/home/username/uploads/' . basename($record['stored_name']);
if (!is_readable($file)) { http_response_code(404); exit; }
header('Content-Type: ' . $record['verified_type']);
header('Content-Disposition: attachment; filename="' . $record['display_name'] . '"');
readfile($file);

Three details matter. The path comes from your database rather than from the request, so no input can traverse out of the directory. The content type is the one you verified at upload, not one derived from the name. And the disposition header tells the browser to download rather than render, which prevents a file being interpreted as HTML in the visitor's browser.

That last point catches the case people miss: an uploaded file served inline can execute script in the context of your domain, even though nothing executed on the server.

Filenames need normalising, not just replacing

Generating your own name solves most of it. Where the original must be preserved for display, it still needs handling.

Names arrive containing path separators, null bytes, right-to-left override characters that make an executable look like an image, and characters that behave differently once written to disk.

Store the original as a label in the database, escaped on output like any other user content, and never use it to construct a path. The two uses are separate and conflating them is where the vulnerability lives.

Quotas per user, not just per file

A size limit stops one enormous file. It does nothing about a thousand acceptable ones.

Any upload path open to registered users needs a cap on total storage and on uploads per period. Without it, one account can fill the hosting account, which stops mail, database writes and sessions at the same moment.

Where uploads are anonymous, that limit has to be by address and time, and it should be low. An anonymous upload form with no rate limit is a file host somebody else will discover before you do. Stopping bots and scrapers explains the mechanism.

Decide how long files are kept

Upload directories grow permanently unless something removes from them, and most applications never do.

Attachments from a contact form six years ago are still there, still containing whatever people sent, and still your responsibility if the account is compromised.

find ~/uploads -type f -mtime +365 | wc -l
du -sh ~/uploads

Set a retention period appropriate to what the files are for, and apply it. Data you no longer need is only a liability. What a website needs for privacy compliance walks through the obligations that make it one.