{ "index": 354, "slug": "editorial-2018-03-practice-safe-uploads", "title": "PHP: безопасная загрузка аватара без доверия к имени файла", "excerpt": "Как принять JPEG или PNG от пользователя, проверить временный файл на сервере, сохранить его под своим ключом и не открыть приложению лишний путь к выполнению кода.", "contentHtml": "
Загрузка аватара обычно начинается с одного поля формы и вызова move_uploaded_file. Ошибка становится заметна позже: каталог uploads оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к .jpg. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.
Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.
\nФорма передаёт один файл avatar. Мы принимаем не более 2 МБ, только image/jpeg и image/png, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например 7f4a...c2.png. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.
| Проверка | Что она отвечает | Что делаем при отказе |
|---|---|---|
UPLOAD_ERR_OK | PHP полностью принял часть запроса | Не читаем временный путь, показываем понятную ошибку загрузки |
| Лимит 2 МБ | Файл укладывается в договор аватара | Не переносим файл и не пытаемся уменьшать его вслепую |
finfo_file | Какой MIME-тип определён по временному файлу | Отклоняем тип, которого нет в белом списке |
| Размеры изображения | Подходит ли картинка для интерфейса | Отклоняем слишком маленькое или слишком большое изображение |
| Сгенерированный ключ | Куда именно будет записан файл | Никогда не составляем путь из исходного имени |
Проверка $_FILES[\"avatar\"][\"error\"] должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле type из $_FILES здесь намеренно не используется: его прислал клиент.
<?php\n\nfunction storeAvatar(array $file, string $privateDir): array\n{\n if (!isset($file['error'], $file['tmp_name'], $file['size'])) {\n throw new RuntimeException('Поле avatar передано в неверном формате');\n }\n\n if ($file['error'] !== UPLOAD_ERR_OK) {\n throw new RuntimeException('PHP не принял файл: код ' . $file['error']);\n }\n\n $maxBytes = 2 * 1024 * 1024;\n if ((int)$file['size'] > $maxBytes) {\n throw new RuntimeException('Аватар больше 2 МБ');\n }\n\n $finfo = finfo_open(FILEINFO_MIME_TYPE);\n if ($finfo === false) {\n throw new RuntimeException('Расширение Fileinfo недоступно');\n }\n\n $mime = finfo_file($finfo, $file['tmp_name']);\n finfo_close($finfo);\n\n $allowed = [\n 'image/jpeg' => 'jpg',\n 'image/png' => 'png',\n ];\n\n if (!is_string($mime) || !isset($allowed[$mime])) {\n throw new RuntimeException('Нужен JPEG или PNG');\n }\n\n $size = getimagesize($file['tmp_name']);\n if ($size === false) {\n throw new RuntimeException('Не удалось прочитать размеры изображения');\n }\n\n list($width, $height) = $size;\n if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) {\n throw new RuntimeException('Размеры изображения вне допустимого диапазона');\n }\n\n $storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];\n $target = rtrim($privateDir, DIRECTORY_SEPARATOR)\n . DIRECTORY_SEPARATOR . $storageKey;\n\n if (!move_uploaded_file($file['tmp_name'], $target)) {\n throw new RuntimeException('Не удалось сохранить аватар');\n }\n\n return [\n 'storageKey' => $storageKey,\n 'mime' => $mime,\n 'width' => $width,\n 'height' => $height,\n ];\n}\nУ move_uploaded_file есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам. Вызов также может перезаписать существующий destination, поэтому случайный ключ не следует считать заменой атомарного создания объекта в хранилище с высокой конкуренцией.
Вызов getimagesize нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">\n <input type="file" name="avatar" accept="image/jpeg,image/png" required>\n <button type="submit">Сохранить аватар</button>\n</form>\nАтрибут accept помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением .jpg и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.
Отрицательный сценарий можно повторить без вредного файла. Создайте обычный текст, назовите его как JPEG и вручную передайте клиентский MIME-тип:
\nmkdir -p fixtures\nprintf 'not an image\\n' > fixtures/not-an-image.jpg\n\ncurl -i -X POST \\\n -F 'avatar=@fixtures/not-an-image.jpg;type=image/jpeg' \\\n https://localhost/profile/avatar.php\nОжидаемый результат задаёт контракт endpoint: запрос отклонён, постоянный объект и ссылка в профиле не созданы. Конкретный HTTP-статус выбирает сервис, например 400 или 415. Значение Fileinfo может зависеть от системной magic-базы, поэтому на целевом стенде нужно зафиксировать фактический ответ, а не подставлять его заранее.
multipart/form-data и передать $_FILES[\"avatar\"] в функцию.storageKey, MIME-тип и размеры рядом с пользователем.Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.
\n