diff --git a/editorial/agent-rewrites/354.json b/editorial/agent-rewrites/354.json index 33a108d..ca864f5 100644 --- a/editorial/agent-rewrites/354.json +++ b/editorial/agent-rewrites/354.json @@ -3,5 +3,5 @@ "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 и веб-сервера. Критерий подтверждает маршрут, но не заявляет, что приложение в целом безопасно для любых файлов.
Загрузка аватара обычно начинается с одного поля формы и вызова 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