Чому перевірка розширення файлу не захищає від завантаження чужого коду
Перевірка виду 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 перед збереженням.