File upload is a feature present in almost every web application, from document uploads and profile photos to report attachments. Unfortunately, it's also one of the most common entry points for attacks: web shells disguised as images, HTML files containing malicious scripts, or files deliberately oversized to overload the server.
This article covers a more thorough approach to validating file and photo uploads in PHP, drawn from experience building an upload helper in a Laravel project. The code examples here are simplified to avoid depending on any specific storage service, so they can be adapted to local disk, S3, or any other storage backend.
Why Extension Validation Alone Isn't Enough
A common pattern looks like this:
$extension = $file->getClientOriginalExtension();
if (!in_array($extension, ['jpg', 'png', 'pdf'])) {
// reject
}
The problem is that getClientOriginalExtension() reads the filename sent by the user's browser. This filename can be manipulated entirely. A file named shell.jpg could actually contain PHP code. The extension is just a label, not proof of the file's actual content.
Layers of Validation to Combine
Secure upload validation shouldn't rely on a single check. It works best as several complementary layers.
1. Validate MIME Type from File Content, Not from the Client
Use a function that reads the file's opening bytes (magic numbers), not the header sent by the browser.
function getRealMimeType(string $path): string
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
return $mime;
}
$allowedMimes = [
'image/jpeg',
'image/png',
'application/pdf',
];
if (!in_array(getRealMimeType($file->getPathname()), $allowedMimes)) {
// reject
}
With finfo, file type detection is based on actual content, so a file with a swapped extension alone won't pass.
2. Limit File Size Up Front
Besides saving resources, a size limit also prevents denial-of-service attacks through oversized files.
$maxFileSize = 2 * 1024 * 1024; // 2 MB
if ($file->getSize() > $maxFileSize) {
// reject, and state the maximum limit in the error message
}
Ideally this limit should also be enforced at the web server level (e.g. client_max_body_size in Nginx or upload_max_filesize in php.ini), not just in application code.
3. Scan Content for Dangerous Patterns
For files like images that shouldn't contain any executable code, a simple check for suspicious patterns can serve as an additional layer.
$content = file_get_contents($file->getPathname());
$dangerousPatterns = ['<?php', '<script', '<html'];
foreach ($dangerousPatterns as $pattern) {
if (stripos($content, $pattern) !== false) {
// reject
}
}
Note that this is not a substitute for antivirus or malware scanning, only a cheap additional filter for the most common cases. For production needs with higher stakes, integrating ClamAV or a third-party scanning service is recommended.
4. Never Trust the Original Filename
A filename from the user can contain path traversal characters like ../../etc/passwd or characters that cause problems on certain filesystems. Always generate a new filename on the server under controlled conditions.
function generateSafeFilename(string $originalName): string
{
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
return time() . '_' . strtolower($safeName) . '.' . strtolower($extension);
}
The final filename should be built from a combination of a timestamp, a random identifier, or a user ID, never copied raw from user input.
5. Store Outside the Web Root, Serve Through Application Code
Uploaded files shouldn't be stored directly in a publicly accessible directory. Store them outside public/, then create a dedicated endpoint that verifies access rights before serving the file.
Route::get('/files/{id}', function ($id) {
$file = File::findOrFail($id);
abort_unless(auth()->user()->can('view', $file), 403);
return response()->file(storage_path('app/private/' . $file->path));
});
This approach prevents a file that passed validation from still being directly executable by the web server, since the server never serves it as a static path.
6. Use a Whitelist, Not a Blacklist
Instead of rejecting a list of known-dangerous file types (which can easily be incomplete), explicitly define which types are allowed.
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf'];
$allowedMimes = ['image/jpeg', 'image/png', 'application/pdf'];
A whitelist approach is far safer because it defaults to rejecting anything unrecognized, rather than only rejecting what's already known to be harmful.
Putting the Layers Together
Here's a validation function that combines all the points above:
function validateUpload($file, array $config): array
{
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, $config['allowed_extensions'])) {
return ['valid' => false, 'message' => 'File extension is not allowed.'];
}
$realMime = getRealMimeType($file->getPathname());
if (!in_array($realMime, $config['allowed_mimes'])) {
return ['valid' => false, 'message' => 'File type does not match its actual content.'];
}
if ($file->getSize() > $config['max_size']) {
return ['valid' => false, 'message' => 'File size exceeds the maximum limit.'];
}
$content = file_get_contents($file->getPathname());
foreach ($config['dangerous_patterns'] as $pattern) {
if (stripos($content, $pattern) !== false) {
return ['valid' => false, 'message' => 'File contains disallowed content.'];
}
}
return ['valid' => true, 'filename' => generateSafeFilename($file->getClientOriginalName())];
}
Closing Thoughts
Secure upload validation isn't about adding a single, "smartest" check. It's about layering several simple safeguards that cover each other's blind spots: MIME type from actual content, size limits, content scanning, controlled filename generation, and storage outside public access. No single layer is perfect on its own, but the combination makes the attack surface far harder to exploit.
For applications with higher security requirements, also consider adding external malware scanning and audit logging for every upload activity, so every incoming file can be traced back to its origin and history.
Top comments (0)