If I had to name the single feature that has caused the most compromised CMS sites I have been asked to clean up over the years, it would be file uploads. Contact forms with attachments, profile avatars, document libraries, media galleries. Every one of them takes a file from an untrusted person and puts it somewhere on your server, and the advice most people follow to secure that flow is one line: “check the file extension.” That check is worth having, but on its own it stops almost nobody. Here is why, and what actually holds.

Why the Extension Check Fails

The problem is that “is this file called something.jpg” and “will this file run as code on my server” are two completely different questions, and attackers spend their time in the gap between them. A denylist that blocks .php gets walked around in a dozen well-known ways:

  • Alternate PHP extensions. Depending on server config, .php3, .php4, .php5, .php7, .phtml, .pht, and .phar can all execute as PHP. A denylist that only knows about .php misses every one of them.
  • Case tricks. shell.pHp slips past a naive lowercase string comparison on some setups.
  • Double extensions. shell.php.jpg passes a check that only looks at the last extension, and older or misconfigured Apache setups will happily execute it as PHP because of how they parse multiple extensions.
  • MIME spoofing. The Content-Type header the browser sends is attacker-controlled. Trusting it to decide “this is an image” is trusting the person uploading the file.
  • The .htaccess upload. This is the clever one. Instead of uploading executable code, the attacker uploads an .htaccess file that tells Apache to execute .jpg files as PHP. Now their innocent-looking avatar.jpg runs. Any upload directory that accepts .htaccess can be turned against you this way.

The lesson is not “write a better denylist.” It is that the extension is the wrong thing to be making a security decision on in the first place.

What Actually Holds: Defence in Layers

No single control below is sufficient. Together they are, because an attacker has to defeat all of them at once.

1. Use an allowlist, never a denylist. List the handful of extensions you genuinely need (say jpg, png, pdf) and reject everything else. You cannot forget to block a dangerous extension you never added to an allow set. This inverts the whole problem in your favour.

2. Verify the content, not just the name. For images, read the actual file and confirm it is what it claims to be rather than trusting the extension or the browser’s MIME header. In PHP, finfo_file() reads the file’s magic bytes, and getimagesize() will fail on something that is not a real image. These are not perfect on their own, a polyglot file can carry a valid image header and PHP code after it, which is exactly why this is one layer and not the whole defence.

3. Rename the file yourself. Do not store the user’s filename. Generate your own random name and attach your own extension from the allowlist. This kills double-extension tricks and the .htaccess and .user.ini uploads in one move, because the attacker no longer controls what the file is called on disk.

4. Stop code from executing in the upload directory. This is the layer that turns a successful malicious upload into a harmless file sitting on disk. Configure the server so that nothing in the uploads folder is ever run as code. On nginx, that is a location block that refuses to pass anything in the uploads path to the PHP handler. On Apache, or on shared hosting where you only have .htaccess, disable the engine for that directory:

# .htaccess placed in the uploads directory
php_flag engine off
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar

And because that file is your defence, make sure your own upload handler refuses to let anyone upload a file named .htaccess or .user.ini. The allowlist and the rename in steps 1 and 3 already do this, which is part of why they matter.

5. Store uploads outside the webroot where you can. If a file never lives under a URL that the web server will execute, it cannot be run no matter what it contains. Serve those files through a script that sets the right headers and streams the bytes, rather than linking to them directly. This is more work to build, and it is the most robust option of all of these.

The CMS-Specific Part

On WordPress, uploads land in wp-content/uploads. Drop a rule there disabling PHP execution, restrict the accepted file types with the upload_mimes filter to only what your site uses, and set define('DISALLOW_FILE_EDIT', true); in wp-config.php so a compromised admin session cannot edit theme and plugin files from the dashboard. On Joomla, tighten the Media Manager’s list of legal extensions and MIME types in Global Configuration rather than leaving the permissive defaults in place. Whatever the platform, the same rule I keep coming back to on this blog applies: keep it patched, because an upload filter does not help you if the vulnerability is in the gallery plugin parsing the file after it lands. I wrote about that in why CMS updates matter more than you think.

Putting It Together

If you do only one thing, disable code execution in your upload directories, because it is the layer that makes every other failure survivable. If you do three, add an allowlist and rename files on the way in. And if your uploads currently trust the extension or the browser’s content type to decide what is safe, treat that as a hole to close this week rather than a project for next quarter. For hardening the directory itself with server rules, my older notes on .htaccess security still cover the mechanics.