The Real Risk Behind Every File Upload Form
Every file upload feature is a potential entry point for arbitrary code execution on your server. The attack path is straightforward: an attacker uploads a PHP script, requests it via HTTP, and the web server executes it with the same privileges as the PHP worker. From that point, the attacker can read database credentials, exfiltrate customer data, use your server to send spam, or pivot to other systems on your network.
The vulnerability is rarely in the upload feature itself. It is in how the uploaded file is stored, served, and processed after the upload completes.
Consider what happens on a typical misconfigured upload form. The HTML form accepts any file type. The PHP script that receives the upload checks the file extension against a whitelist but does not check the file content. The uploaded file is saved in /var/www/html/uploads/ with the original filename preserved. An attacker uploads shell.php.jpg (a PHP script with a .jpg extension) and then requests /uploads/shell.php.jpg. On many Apache configurations, the .jpg extension is not handled by PHP, so the file is served as static content. But if the attacker renames it to shell.php, or if the server uses a configuration that processes .jpg as PHP, the script executes.
The correct approach has three layers: validate file content, store files outside the web root, and serve them through a PHP script that enforces access controls before outputting the file contents.
Storing Files Outside the Document Root
The foundational control is storing uploaded files in a location the web server cannot serve directly. If your document root is /var/www/html/, store uploads in /var/www/uploads/ or /srv/storage/uploads/. The web server receives HTTP requests and maps the URL path to a file on disk. If the file is outside the document root, there is no URL that makes the web server return that file. The only way to access the file is through a PHP script you write, which means you control who can download it and what happens before the file is delivered.
# Create the upload directory outside the document root
mkdir -p /srv/storage/uploads
chown -R www-data:www-data /srv/storage/uploads
chmod -R 750 /srv/storage/uploads
The upload form should post to a PHP endpoint. That endpoint moves the file from the temporary upload location to the storage directory using a generated filename, not the original filename. The original filename is stored in a database alongside the generated filename and any metadata. When a user wants to download the file, they request it through a download endpoint that looks up the file by ID, reads it from the storage directory, and outputs it with the correct Content-Type header.
Using the original filename directly introduces a path traversal risk. An upload with filename "../../../etc/passwd" and a script that writes to $_FILES directly could overwrite system files if the write path is not properly sanitised. Generate a filename using a method that cannot produce path traversal characters:
<?php
$upload_dir = '/srv/storage/uploads/';
$original_name = $_FILES['upload']['name'];
// Generate a random filename, preserve only the last extension
$extension = strtolower(pathinfo($original_name, PATHINFO_EXTENSION));
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];
if (!in_array($extension, $allowed_extensions, true)) {
http_response_code(400);
exit('Invalid file type');
}
$generated_name = bin2hex(random_bytes(16)) . '.' . $extension;
$target_path = $upload_dir . $generated_name;
if (!move_uploaded_file($_FILES['upload']['tmp_name'], $target_path)) {
http_response_code(500);
exit('Upload failed');
}
// Store the mapping (generated_name, original_name, user_id, upload_time) in your database
?>
Validating File Content, Not Just Extensions
Extension validation is bypassable. A file named malicious.php.jpg contains PHP code, not a valid JPEG image. The .jpg extension tells the browser what MIME type to report, not what the file actually contains. Use PHP's built-in functions to verify the file's actual content type.
For images, use getimagesize() or exif_imagetype(). Both functions attempt to parse the file as an image format and return metadata or an image type constant. If the file is not a valid image, the function returns false or throws an error. For PDF documents, use a library like Smalot/PdfParser or check the file signature (the first bytes of a PDF file are always %PDF-). For other file types, use finfo_file() with the FILEINFO_MIME_TYPE constant to get the actual MIME type from the file's content, not from the browser-reported Content-Type header.
<?php
// Check actual MIME type using file signature
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime_type = $finfo->file($_FILES['upload']['tmp_name']);
$allowed_mimes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/pdf' => 'pdf'
];
if (!array_key_exists($mime_type, $allowed_mimes)) {
http_response_code(400);
exit('File type not permitted');
}
// Additional image validation for images
$image_mimes = ['image/jpeg', 'image/png', 'image/gif'];
if (in_array($mime_type, $image_mimes, true)) {
$image_info = @getimagesize($_FILES['upload']['tmp_name']);
if ($image_info === false) {
http_response_code(400);
exit('Invalid image file');
}
// Check image dimensions to prevent pixel flood attacks
if ($image_info[0] > 10000 || $image_info[1] > 10000) {
http_response_code(400);
exit('Image dimensions excessive');
}
}
?>
The MIME type check using finfo is reliable because it reads the file's binary content and matches it against a database of known file signatures, not the browser-supplied Content-Type header. The additional image parsing with getimagesize() catches cases where a file has a valid image MIME type but is a crafted file designed to exploit parser vulnerabilities. The dimension check prevents pixel flood DoS attacks where a specially crafted small file expands to enormous memory usage when loaded into an image library.
Blocking PHP Execution in the Upload Directory
Even with correct storage outside the web root, add a layer of defence that prevents PHP execution even if files are somehow placed within the web root. This catches mistakes where the storage path is accidentally placed inside the document root, or where a rewrite rule misconfiguration allows access to files outside the intended path.
# In the upload directory .htaccess (Apache)
<FilesMatch "\.ph(p?|tml|ar)$">
Order Deny,Allow
Deny from all
</FilesMatch>
# For Nginx, in the upload location block
location /srv/storage/uploads {
internal;
# Deny all PHP execution attempts explicitly
location ~ \.php$ {
deny all;
}
}
Set the upload directory permissions so that the web server user can write files but cannot execute them. Permissions of 755 on directories allow traversal but not execution of files. Setting the directory to 750 (owner read/write/execute, group read/execute, no access for others) and ensuring files are created with 640 permissions prevents execute permission from being set on uploaded files:
chmod 750 /srv/storage/uploads
# Files uploaded will inherit the directory group (www-data)
# Explicitly set file permissions after upload
chmod 640 $target_path
PHP's open_basedir restriction limits which filesystem paths PHP scripts can access. Set it to the application root and the upload storage directory only. This does not prevent execution of already-uploaded files but limits what a compromised PHP script can access on the filesystem:
# In php.ini or php-fpm pool config
open_basedir = /var/www/html:/srv/storage/uploads:/var/log/php
For more guidance on securing server configurations, including proper permission setup and access restrictions, a practical server security review can help identify misconfigurations across the stack.
The Download Script: Enforcing Access Control Before Serving Files
Files stored outside the web root are inaccessible via direct URL. The only access path is through a PHP download script that you control. This script is a security control layer. It enforces authentication, checks authorisation (does this user own this file or have permission to access it?), and serves the file correctly before outputting it.
<?php
session_start();
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
exit('Authentication required');
}
$file_id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($file_id === false || $file_id === null) {
http_response_code(400);
exit('Invalid file ID');
}
$pdo = new PDO('mysql:host=localhost;dbname=app', 'app_user', 'password');
$stmt = $pdo->prepare("SELECT filename, original_name, mime_type FROM uploads WHERE id = ? AND user_id = ?");
$stmt->execute([$file_id, $_SESSION['user_id']]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
http_response_code(404);
exit('File not found');
}
$filepath = '/srv/storage/uploads/' . $file['filename'];
if (!file_exists($filepath)) {
http_response_code(404);
exit('File not found on disk');
}
header('Content-Type: ' . $file['mime_type']);
header('Content-Disposition: inline; filename="' . $file['original_name'] . '"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
?>
Never accept a filename from the request and pass it directly to readfile(). A request like /download.php?file=../../../etc/passwd is a path traversal attack if the script is not careful. Using an integer file ID and looking up the actual filename from a database eliminates this risk entirely. There is no user-supplied path involved in the filesystem read.
Scanning Uploaded Files for Malware
For higher-security applications, integrate ClamAV to scan uploaded files before they are stored. A PHP script that saves a file and then runs it through clamscan before confirming the upload to the user can catch malicious files that bypass content validation:
<?php
// Move uploaded file to temp location
$temp_path = $_FILES['upload']['tmp_name'];
$scan_result = shell_exec('clamscan --remove=no ' . escapeshellarg($temp_path) . ' 2>&1');
if (strpos($scan_result, 'FOUND') !== false) {
unlink($temp_path);
http_response_code(400);
exit('File rejected: malware detected');
}
// File is clean, move to storage
move_uploaded_file($temp_path, $target_path);
?>
The --remove=no flag tells ClamAV not to delete the file if found. That is your responsibility. Running the scan in a PHP script adds latency to the upload process. Consider running ClamAV as a daemon (clamd) and using the clamdscan command or a PHP extension for faster scanning without the per-file shell spawn overhead.
A simpler heuristic that catches most malicious PHP uploads: check if the file content contains <?php or <script. These patterns should not appear in user-uploaded content. Reject the file if they are found:
<?php
$content = file_get_contents($_FILES['upload']['tmp_name']);
if (preg_match('/<\?php/i', $content) || preg_match('/<script.*>/i', $content)) {
http_response_code(400);
exit('File contains disallowed content');
}
?>
For broader website security considerations, including how upload vulnerabilities fit into a wider security posture, a practical security audit approach can help identify related weaknesses across your application.
Rate Limiting Upload Requests
An attacker can fill your disk by uploading many small files rapidly, even if each file is within the size limit. Implement per-user or per-IP rate limiting on upload requests. Use Redis to track upload counts:
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$ip = $_SERVER['REMOTE_ADDR'];
$key = 'upload_rate:' . $ip;
$count = (int)$redis->get($key);
if ($count >= 10) {
http_response_code(429);
exit('Upload rate limit exceeded. Try again later.');
}
$redis->incr($key);
if ($count === 0) {
$redis->expire($key, 3600); // Reset counter every hour
}
// Proceed with upload
?>
Configure PHP-level upload limits in php.ini. The upload_max_filesize and post_max_size directives set the maximum size for individual uploads and the total size of a POST request respectively. Set them to values slightly above what your application actually needs, not arbitrary large values:
; In php.ini
upload_max_filesize = 5M
post_max_size = 10M
Configure Nginx to enforce request body size limits at the web server level, so large uploads are rejected before they reach PHP:
# In Nginx server block
client_max_body_size 5M;
Logging Upload Activity
Log every upload attempt: the authenticated user (if any), the original filename, the generated storage filename, the file size, the detected MIME type, the result (success or failure), and the IP address of the uploader. This log is essential for debugging user issues and for detecting attack patterns. A sudden spike in failed upload attempts from a single IP indicates an attempted exploit.
<?php
$log_data = [
'user_id' => $_SESSION['user_id'] ?? null,
'ip' => $_SERVER['REMOTE_ADDR'],
'original_name' => $_FILES['upload']['name'],
'generated_name' => $generated_name,
'size' => $_FILES['upload']['size'],
'mime_type' => $mime_type,
'result' => 'success',
'error' => null
];
// Write to a JSON log file or send to a logging service
file_put_contents('/var/log/uploads.json', json_encode($log_data) . "\n", FILE_APPEND);
?>
Retain upload logs for at least 90 days. If you discover a security incident involving uploaded files, the logs tell you what was uploaded, by whom, when, and the IP address of the uploader. This information is also relevant for GDPR data subject access requests, where you may need to demonstrate what data was processed and for what purpose.
SVG Uploads Require Special Handling
SVG files are XML documents that can contain embedded JavaScript (event handlers, onload, animate elements). An SVG uploaded as a profile picture and then displayed in an img tag or as an inline SVG can execute JavaScript in the viewer's browser if the SVG contains script tags. Browsers block script execution in SVG files referenced as images, but an SVG served with an image/svg+xml content type and displayed inline can execute scripts. This makes SVG a higher-risk upload type than raster images.
If you accept SVG uploads, sanitise them using a library like DOMPurify (for HTML/SVG sanitisation) or sanitize-html before storing them. Alternatively, convert SVG uploads to raster format (PNG) on the server using Imagick or Inkscape CLI and store the converted file instead of the original SVG. This eliminates the embedded script risk entirely:
<?php
// Convert SVG to PNG using Imagick
$imagick = new Imagick();
$imagick->readImageBlob(file_get_contents($uploaded_svg_path));
$imagick->setImageFormat('png');
$png_path = preg_replace('/\.svg$/', '.png', $target_path);
$imagick->writeImage($png_path);
$imagick->destroy();
// Delete the original SVG
unlink($target_path);
$target_path = $png_path;
?>
Content-Disposition Header Configuration
When serving uploaded files, set the Content-Disposition header correctly. Use inline for files the browser should display (images, PDFs), and attachment for files that should be downloaded (ZIP archives, executables, Office documents that the user should open in a desktop application). Forcing all files to attachment is safe but degrades the user experience for files that could be previewed inline.
<?php
// Determine disposition based on MIME type
$inline_mimes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
$disposition = in_array($file['mime_type'], $inline_mimes, true) ? 'inline' : 'attachment';
header('Content-Disposition: ' . $disposition . '; filename="' . addslashes($file['original_name']) . '"');
?>
Never use the user-supplied original filename directly in the Content-Disposition header without escaping it. A filename containing quotes, backslashes, or non-ASCII characters can cause header injection or be truncated by browsers. Use addslashes() or a dedicated header-safe encoding function. The actual filename stored in your database is what you control. Always use that.