Почему проверка расширения файла не защищает от загрузки чужого кода
Проверка вида if (strpos($_FILES['file']['name'], '.php') !== false) ничего не решает: злоумышленник переименует shell.php в shell.php.jpg, а на некоторых конфигурациях Apache с mod_mime сервер всё равно выполнит его как PHP из-за составного расширения. Расширение файла — это метаданные, которые полностью контролирует тот, кто отправляет форму, а не признак реального содержимого.
То же самое касается заголовка Content-Type из $_FILES['file']['type']: браузер подставляет его на основе расширения на стороне клиента, и curl или Postman позволяют указать любое значение вручную. Доверять этому полю в проверках безопасности нельзя вообще.
Проверка реального типа файла через finfo
Единственный надёжный способ — прочитать сигнатуру файла и определить MIME-тип по содержимому, а не по имени или заголовку запроса.
$finfo = new finfo(FILEINFO_MIME_TYPE);
$realType = $finfo->file($_FILES['avatar']['tmp_name']);
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($realType, $allowed, true)) {
throw new RuntimeException('Недопустимый тип файла: ' . $realType);
}
Проверка идёт по временному файлу $_FILES['avatar']['tmp_name'], который PHP уже сохранил на диск после загрузки, — именно его содержимое и нужно анализировать, а не оригинальное имя файла. Исключение RuntimeException стоит логировать, а не показывать пользователю напрямую — принципы разобраны в статье о логах и разборе фаталов PHP.
Ограничение размера: php.ini и код
Размер загружаемого файла ограничивается на двух уровнях. Сначала — php.ini, до того как скрипт вообще получит управление.
upload_max_filesize = 10M
post_max_size = 12M
Значение post_max_size должно быть больше upload_max_filesize, иначе PHP обрежет весь запрос ещё до того, как разберёт файлы, и $_FILES окажется пустым без явной ошибки. Подробный разбор этих директив и типовых ошибок — в статье про memory_limit и upload_max_filesize. Второй уровень — проверка $_FILES['avatar']['size'] в коде до вызова move_uploaded_file, если бизнес-логике нужен лимит меньше, чем в php.ini.
Куда сохранять загруженные файлы
| Правило | Плохо | Хорошо |
|---|---|---|
| Расположение | внутри веб-корня /public | вне веб-корня, отдаётся скриптом |
| Имя файла | оригинальное имя от пользователя | случайное имя через random_bytes |
| Права доступа | 0777 на каталог загрузок | 0755 на каталог, 0644 на файлы |
| Исполнение | PHP разрешён в каталоге загрузок | php_admin_flag engine off в конфиге |
Если каталог загрузок физически лежит внутри веб-корня, добавьте в конфигурацию php_admin_flag engine off именно для этого пути — тогда даже загруженный файл с расширением .php выполнится не как код, а отдастся как обычный текст. Общие принципы усиления php.ini собраны в статье о безопасности PHP через настройки.
Частые ошибки при приёме файлов
- Доверие расширению или Content-Type из запроса вместо проверки содержимого через finfo.
- Сохранение файла под оригинальным именем — путь вида ../../config.php в имени файла даёт запись за пределы каталога загрузок.
- Отсутствие ограничения на количество файлов в одном запросе — это открывает почву для DoS через тысячи мелких загрузок.
- move_uploaded_file вызывается без предварительной проверки is_uploaded_file, из-за чего скрипт можно обмануть подложным путём.
- Загруженные изображения не перекодируются через GD или Imagick — вредоносный код можно спрятать внутри валидного на вид JPEG.
Итог: чек-лист безопасной загрузки файлов
- Реальный тип файла проверяется через finfo по содержимому, а не по расширению или Content-Type.
- upload_max_filesize и post_max_size заданы в php.ini, лишний лимит по размеру проверяется в коде.
- Файлы сохраняются под случайным именем вне веб-корня или с отключённым исполнением PHP.
- Каталог загрузок имеет права 0755/0644, а не 0777.
- Изображения проходят перекодировку через GD или Imagick перед сохранением.