{ "index": 354, "slug": "editorial-2018-03-practice-safe-uploads", "title": "PHP: безопасная загрузка аватара без доверия к имени файла", "excerpt": "Как принять JPEG или PNG от пользователя, проверить временный файл на сервере, сохранить его под своим ключом и не открыть приложению лишний путь к выполнению кода.", "contentHtml": "
Симптом появляется не в форме. Кнопка сообщает «файл сохранён», но в каталоге загрузок лежит имя из запроса, каталог доступен из веб-корня, а сервер проверил только суффикс .jpg. Иногда ошибка видна сразу: приложение сохраняет текстовый файл с клиентским Content-Type: image/jpeg. Иногда она ждёт следующего шага: другой обработчик отдаёт загруженный файл как ресурс или передаёт его конвертеру.
Цена ошибки — не только испорченный аватар. Пользователь может перезаписать чужой файл, получить неожиданный контент по предсказуемому URL или передать файл компоненту, который не рассчитан на такой вход. Само наличие move_uploaded_file() проблему не решает. Функция подтверждает, что исходный путь связан с HTTP-загрузкой PHP. Она не подтверждает, что файл подходит бизнес-правилу.
Тезис: безопасная загрузка начинается с узкого контракта. Сервер принимает один файл, проверяет результат доставки, размер и содержимое временного файла, выбирает расширение сам, переносит файл в хранилище вне веб-корня и сохраняет в профиле только ключ приложения. Имя, расширение и MIME-тип из формы остаются данными клиента.
\nБраузер отправляет multipart-запрос. PHP принимает его и создаёт временный файл. В $_FILES['avatar'] появляются имя, тип, размер, временный путь и код ошибки. Первые три значения полезны для интерфейса и диагностики, но не дают серверу достаточного основания разрешить сохранение.
Обработчик должен разделить четыре решения. Сначала он проверяет, что PHP действительно принял часть запроса. Затем ограничивает размер. После этого определяет MIME-тип по временному файлу и сверяет его с белым списком. Для изображения он отдельно читает размеры. Только после всех проверок приложение создаёт имя и переносит файл.
\n/assets/editorial/2018/php-upload-avatar-contract.svg.В примере ниже договор относится только к аватару. Он принимает один JPEG или PNG размером до 2 МБ и с шириной и высотой от 64 до 3000 пикселей. Эти числа — учебные ограничения, а не универсальная норма. Продукт должен выбрать их по своему интерфейсу, лимитам PHP и доступному диску.
\nФункция получает массив одного файла и абсолютный путь к закрытому каталогу. Она не использует $file['name'] при построении пути. Поле $file['type'] тоже не участвует в решении: его передаёт клиент. Серверный MIME-тип получается из временного файла через Fileinfo.
<?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 $imageSize = getimagesize($file['tmp_name']);\n if ($imageSize === false) {\n throw new RuntimeException('Не удалось прочитать изображение');\n }\n\n [$width, $height] = $imageSize;\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В реальном приложении каталог должен существовать заранее, принадлежать ожидаемому пользователю процесса и быть недоступен как исполняемая директория веб-сервера. Приложение должно проверить права текущего пользователя до записи и обработать исключение на уровне HTTP-ответа. Код выше — учебный пример порядка проверок, а не готовый пакет для любого типа файла.
\nСгенерированный ключ не содержит разделителей пути из имени клиента и не обязан совпадать с ним. Если ключ строится случайно, одинаковые имена вроде avatar.jpg не создают коллизии. Расширение берётся из принятого MIME-типа, а не копируется из $file['name'].
Если возможна замена аватара, сначала сохраните новый файл, затем атомарно поменяйте ссылку в профиле и только после успешной транзакции удалите старый объект. Иначе ошибка записи профиля оставит пользователя без прежнего изображения. Это отдельный жизненный цикл; функция выше отвечает только за приём и перенос.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Сохраняется файл с любым расширением | Путь строится из name | Отправить ../../other.jpg и имя с пробелами | Игнорировать имя; создать ключ приложением |
| Текст проходит как изображение | Проверяется только type или суффикс | Послать текстовый файл с именем photo.jpg | Определить тип временного файла и применить белый список |
| Большой файл падает позже | Лимит проверяет только форма | Отправить файл больше договорного размера | Отклонить до чтения изображения и проверить лимиты PHP |
| Файл доступен по угадываемому URL | Хранилище лежит в веб-корне | Открыть URL каталога без UI приложения | Перенести объект за веб-корень; выдавать его через контроллер |
| Профиль ссылается на отсутствующий объект | Ссылка записана раньше переноса или без проверки результата | Сымитировать ошибку прав на каталог | Сохранять ссылку только после успешного переноса и проверять возврат |
Таблица описывает проверки маршрута, а не доказательство полной безопасности сервиса. Другой endpoint всё ещё может принимать тот же файл без этой функции. Правило должно быть общим для всех входов или явно ограниченным одним маршрутом.
\n<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>\nenctype='multipart/form-data' нужен для передачи файла. accept ограничивает подсказку выбора в браузере. Оба атрибута не являются границей доверия. Запрос можно собрать вручную через curl или другой HTTP-клиент. Сервер должен одинаково обработать такой запрос и обычную отправку формы.
UPLOAD_ERR_OK. При ошибке завершить запрос до работы с временным путём.upload_max_filesize и post_max_size.getimagesize() как единственный security-валидатор.move_uploaded_file() и проверить результат..jpg, файл больше лимита и ошибку записи в хранилище.Этот пример не сканирует вредоносное содержимое, не устраняет CSRF и не делает безопасную перекодировку изображения. Он не защищает от исчерпания диска, чрезмерного числа запросов или особенностей библиотек, которые будут читать файл дальше. Для документов, архивов и видео нужны отдельные правила: другой белый список, лимиты распаковки или декодирования и отдельный способ выдачи.
\nДаже для JPEG и PNG MIME-проверка не даёт права бездумно отправлять файл в ImageMagick, браузер или другой сервис. Каждый следующий потребитель должен иметь собственный лимит и обработку ошибки. Если изображение публичное, выдавайте его через заранее определённый URL и правильный заголовок типа. Если доступ зависит от пользователя, сначала проверяйте право, а затем отдавайте объект из закрытого хранилища.
\nПри любой проверочной ошибке обработчик не переносит временный файл, не создаёт ссылку в профиле и не сообщает клиенту внутренний путь. Логируйте код отказа и идентификатор операции, но не исходное имя без необходимости. Пользователю достаточно сообщения «файл не принят» и понятного объяснения лимита.
\nМаршрут готов для этого учебного контракта, если пять проверок дают наблюдаемый результат: допустимые JPEG и PNG появляются в закрытом хранилище под сгенерированными ключами; текстовый файл с расширением .jpg отклоняется; слишком большой файл отклоняется до переноса; ошибка прав не создаёт ссылку в профиле; прямой URL каталога не раскрывает объект. Проверку нужно выполнить на целевой конфигурации PHP и веб-сервера. Критерий подтверждает маршрут, но не заявляет, что приложение в целом безопасно для любых файлов.