Never Trust a File Extension: Real MIME Detection in PHP
Here's an unpleasant demo. Take shell.php, rename it to vacation.jpg, upload it to a form that checks the extension and the $_FILES type field. Both checks pass. The extension is whatever the attacker typed, and $_FILES['upload']['type'] is sent by the client - it's literally a request header wearing a trustworthy name.
Read the bytes instead
The fileinfo extension inspects actual file content - magic numbers, structure - the way the file command does:
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realType = $finfo->file($_FILES['upload']['tmp_name']);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'application/zip' => 'zip',
];
if (!isset($allowed[$realType])) {
http_response_code(422);
exit('File type not allowed.');
}
The renamed PHP script comes back as text/x-php no matter what the filename claims. Note the allowlist shape: you enumerate what's acceptable, never blocklist what's scary - blocklists lose to the file type you didn't think of.
The full layer cake
Content sniffing is the load-bearing layer, not the only one:
// 1. Never use the client's filename for anything but display
$ext = $allowed[$realType]; // extension derived from REAL type
$name = bin2hex(random_bytes(16)) . '.' . $ext; // generated, collision-proof name
// 2. Move outside the webroot (or to a no-execute location)
move_uploaded_file($_FILES['upload']['tmp_name'], UPLOAD_DIR . $name);
// 3. For images: re-encoding is the strongest sanitizer there is
$img = imagecreatefromstring(file_get_contents(UPLOAD_DIR . $name));
if ($img === false) { unlink(UPLOAD_DIR . $name); exit('Corrupt image.'); }
imagejpeg($img, UPLOAD_DIR . $name, 85); // rebuilt pixels, payloads gone
Why each layer: generated names kill path traversal and the double-extension trick (photo.php.jpg); storing outside the webroot means even a smuggled script is never executable by URL (serve files through a PHP readfile endpoint or an aliased static path with PHP disabled); re-encoding images destroys anything hiding in EXIF or appended to the file, because you're saving new pixels, not the original bytes.
Loose ends worth knowing
- SVG is an XML document that can carry scripts - treat it as code, not as an image. Don't accept it from untrusted users unless you sanitize it properly.
- finfo can misread some formats (CSVs are notoriously fuzzy - they're just text). For text formats, validate structure yourself instead.
- Check
$_FILES['upload']['error'] === UPLOAD_ERR_OKand enforce size limits before any of this runs.
The same paranoia applies to pasted-screenshot uploads - the transport being fancy doesn't make the bytes trustworthy. Validate content, generate names, store dumb. That trio has never let me down.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.