File Upload Security
Why Uploads Are Dangerous In simple terms: A file upload lets strangers place files on your server. If an attacker uploads a PHP script and can then open it in the browser, the server runs their code
Why Uploads Are Dangerous
In simple terms: A file upload lets strangers place files on your server. If an attacker uploads a PHP script and can then open it in the browser, the server runs their code — they can read the database password, change pages, or take over the server. Other dangerous uploads include HTML or SVG files that run JavaScript in your domain, oversized files that fill the disk, and malware passed on to other users.
Technically: Upload handling is a chain of decisions, and a weakness in any link can be exploited: trusting client-supplied names and MIME types, storing files where the web server executes them, serving user files with dangerous content types from your own origin, missing size and count limits, and unsafe processing (image libraries, archive extraction). Secure handling applies defense in depth: validate, rename, store safely, serve safely, and limit resources.
Threats and Countermeasures
| Threat | Example | Countermeasure |
|---|---|---|
| Remote code execution | shell.php (or shell.php.jpg, shell.phtml) uploaded and requested via URL | Store outside the web root; disable script execution in upload folders; never keep the client's file name |
| MIME and extension spoofing | A PHP file renamed to photo.jpg with Content-Type: image/jpeg | Detect the real type with finfo; derive the extension from the detected type |
| Stored XSS | An .html or .svg file containing <script> opened from your domain | Do not allow HTML/SVG uploads (or sanitize them); serve with safe headers; use a separate domain for user content |
| Path traversal | File name ../../index.php | Never use the client's name in paths |
| Denial of service | Huge files, thousands of uploads, decompression bombs | Size and count limits, quotas, dimension checks, rate limiting |
| Malware distribution | Infected documents shared with other users | Antivirus scanning; restrict file types |
The Secure Upload Pipeline
1. Check the upload error code and size
2. Detect the real MIME type from the content (finfo)
3. Allow only expected types; choose the extension from the detected type
4. Process if possible (re-encode images)
5. Generate a random file name
6. Move to private storage outside the web root (move_uploaded_file)
7. Record metadata in the database
8. Serve through a script with safe headers and permission checks
Choosing Extensions From Detected Types
<?php
const ALLOWED_UPLOAD_TYPES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
foreach (['image/jpeg', 'application/pdf', 'image/svg+xml', 'text/html', 'application/x-php'] as $detected) {
echo str_pad($detected, 18) . '→ ' . (ALLOWED_UPLOAD_TYPES[$detected] ?? 'rejected') . "\n";
}
$storedName = bin2hex(random_bytes(16)) . '.' . ALLOWED_UPLOAD_TYPES['image/jpeg'];
var_dump(preg_match('/^[a-f0-9]{32}\.jpg$/', $storedName));
Output
image/jpeg → jpg
application/pdf → pdf
image/svg+xml → rejected
text/html → rejected
application/x-php → rejected
int(1)
The client's file name (shell.php.jpg, ../../index.php, invoice.pdf) plays no role in where or how the file is stored. The stored name is 32 random hex characters plus an extension chosen by the server — it cannot contain .php, path separators, or anything else the attacker controls.
A Complete Upload Handler
<?php
function storeUpload(array $file, string $storageDir, int $maxBytes): array
{
if (!isset($file['error']) || is_array($file['error'])) {
return ['ok' => false, 'error' => 'Invalid upload.'];
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'The upload failed. Please try again.'];
}
if ($file['size'] <= 0 || $file['size'] > $maxBytes) {
return ['ok' => false, 'error' => 'The file is empty or too large.'];
}
if (!is_uploaded_file($file['tmp_name'])) {
return ['ok' => false, 'error' => 'Invalid upload.'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extension = ALLOWED_UPLOAD_TYPES[$mime] ?? null;
if ($extension === null) {
return ['ok' => false, 'error' => 'This file type is not allowed.'];
}
$storedName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = rtrim($storageDir, '/') . '/' . $storedName;
if (!move_uploaded_file($file['tmp_name'], $target)) {
return ['ok' => false, 'error' => 'The file could not be saved.'];
}
chmod($target, 0640);
return [
'ok' => true,
'stored_name' => $storedName,
'mime' => $mime,
'size' => $file['size'],
'original_name' => mb_substr(basename((string) $file['name']), 0, 150),
];
}
What Each Step Prevents
- Error and structure checks reject failed uploads and attempts to send arrays where a single file is expected.
- Size limits prevent disk exhaustion; keep
upload_max_filesizeandpost_max_sizeinphp.inireasonable as well. is_uploaded_file()/move_uploaded_file()guarantee the file really came from this HTTP upload, not from a path an attacker supplied.finfodetects the type from the file's content; the browser's$file['type']is ignored because the client controls it.- Random names and server-chosen extensions prevent code execution, overwriting, and path traversal.
chmod(0640)makes the file readable by its owner and group only, and never executable.- The original name is kept only as metadata (shortened, without path), and must be escaped whenever displayed.
For images, go one step further and re-encode them with GD or Imagick (see PHP File Upload). Re-encoding strips embedded payloads — such as PHP code hidden in image metadata ("polyglot" files) — and private EXIF data.
Storing Files Where Code Cannot Run
Best: Outside the Web Root
/var/www/shop/
├── public/ ← web server document root
│ └── index.php
└── storage/
└── uploads/ ← not reachable by URL at all
Files outside public/ cannot be requested directly; a PHP script serves them after checking permissions.
If Uploads Must Be Inside the Web Root
Disable script execution for the upload directory in the web server:
# Nginx
location ^~ /uploads/ {
location ~ \.(php|phtml|phar)$ {
deny all;
}
}
# Apache (.htaccess in the uploads folder)
<FilesMatch "\.(php|phtml|phar|php[0-9])$">
Require all denied
</FilesMatch>
These rules are a second layer. Random names with safe extensions remain the primary defense.
Serving Uploaded Files Safely
<?php
// download.php?id=42 — after checking that the current user may access file 42
header('Content-Type: ' . $file['mime']);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header("Content-Security-Policy: default-src 'none'; sandbox");
$inline = str_starts_with($file['mime'], 'image/');
$filename = preg_replace('/[^A-Za-z0-9._-]/', '_', $file['original_name']);
header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $filename . '"');
readfile($path);
| Header | Purpose |
|---|---|
Content-Type | The detected type from upload time, never guessed from the name |
X-Content-Type-Options: nosniff | Stops browsers from "sniffing" the content and treating a file as HTML |
Content-Security-Policy: …; sandbox | Even if a file is rendered, it cannot run scripts or access your origin |
Content-Disposition: attachment | Downloads documents instead of displaying them in the browser |
Large platforms serve user content from a separate domain (for example, usercontent.example), so any script that slips through cannot access the main site's cookies or pages.
Additional Protections
| Measure | Purpose |
|---|---|
Limit max_file_uploads and request sizes | Prevent mass uploads in one request |
| Per-user quotas and rate limiting | Prevent storage abuse |
| Antivirus scanning (for example, ClamAV) | Detect known malware before sharing files |
| Image dimension limits | Prevent decompression bombs when processing |
| Safe archive handling | Validate entry paths when extracting ZIP files ("Zip Slip"), and limit extracted size |
| Authentication for uploads | Reduce anonymous abuse |
Common Mistakes
Keeping the Client's File Name
Effect: Code execution (.php), overwriting, path traversal.
Fix: Generate random names; choose extensions from the detected type.
Trusting $_FILES['file']['type'] or the Extension
Effect: Malicious files pass validation.
Fix: Detect the type with finfo and use an allow-list.
Storing Uploads in an Executable Web Directory
Effect: Uploaded scripts can be run via URL.
Fix: Store outside the web root, or disable execution for the folder.
Allowing HTML or SVG Uploads From Users
Effect: Stored XSS on your domain.
Fix: Disallow them, sanitize them, or serve them from a separate sandboxed domain.
No Limits
Effect: Full disks and slow servers.
Fix: Enforce size, count, dimension, and quota limits.
Best Practices
- Validate uploads by content, with an allow-list of types and server-chosen extensions.
- Rename every file randomly and store it outside the web root with restrictive permissions.
- Re-encode images and scan documents where possible.
- Serve files through a script with correct
Content-Type,nosniff, a sandboxing CSP, andContent-Disposition. - Limit sizes, counts, and quotas, and check permissions before every download.
Official reference: https://www.php.net/manual/en/features.file-upload.common-pitfalls.php