diff --git a/editorial/agent-rewrites/353.json b/editorial/agent-rewrites/353.json index 833391f..0455519 100644 --- a/editorial/agent-rewrites/353.json +++ b/editorial/agent-rewrites/353.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-03-mechanism-safe-uploads", "title": "Безопасная загрузка в PHP: почему расширение и MIME из запроса не доказывают тип файла", "excerpt": "Разделяем сведения клиента, результат приёма PHP и проверку временного файла. На примере JPEG и PNG показываем порядок проверок, отрицательный путь и границу между допуском файла и его хранением.", - "contentHtml": "
Симптом появляется не в момент выбора файла. Обработчик принимает запрос, видит type=image/jpeg, переносит временный файл и сохраняет имя из формы. Позже в каталоге оказывается текстовый файл с расширением .jpg или файл, который открывает неожиданный обработчик. Цена ошибки — произвольное содержимое в хранилище, публичная раздача чужих данных и лишняя точка для атаки. Если путь лежит внутри веб-корня, ошибка становится доступной по URL. Если имя попадает в путь без нормализации, запрос может повредить чужой файл или запись.
Тезис простой: загрузка безопаснее, когда приложение принимает решение по нескольким независимым признакам. Имя, расширение и $_FILES['type'] описывают сообщение клиента. $_FILES['error'] сообщает, как PHP принял часть запроса. finfo_file() исследует временный файл на сервере. Эти сведения нельзя заменить одним условием if ($file['type'] === 'image/jpeg').
Браузер отправляет файл как часть multipart/form-data. В этой части есть имя и Content-Type. Отправитель может быть не браузером: запрос легко собрать через curl или другой HTTP-клиент. Поэтому Content-Type — полезная подсказка, но не доказательство.
После приёма PHP создаёт временный файл и заполняет массив $_FILES. Приложение получает исходное имя, клиентский тип, размер, временный путь и код доставки. Затем оно должно решить, подходит ли файл конкретному сценарию. Для аватара это может быть белый список JPEG и PNG, лимит 2 МБ и диапазон размеров. Для PDF нужны другие правила. Один общий валидатор для всех форматов быстро превращается в набор исключений.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Текст принят как JPEG | Условие читает клиентский type | Сравнить $_FILES['type'] и результат finfo_file() | Убрать клиентский тип из решения о допуске |
| Файл не найден после ошибки формы | Код доставки обработали после чтения временного пути | Проверить $_FILES['error'] до остальных полей | Остановиться при любом коде, кроме UPLOAD_ERR_OK |
| Имя меняет путь сохранения | Путь собран из name или расширения | Отправить имя с пробелами, слешами и повтором | Сгенерировать ключ сервером и хранить имя отдельно |
| Большая картинка расходует ресурсы | Проверяется только MIME-тип | Сравнить размер файла и размеры изображения с лимитами | Отказать до декодирования и переноса |
| Файл доступен по неожиданному URL | Хранилище лежит в веб-корне или выдаёт путь напрямую | Проверить URL, права каталога и маршрут чтения | Хранить вне веб-корня или выдавать через авторизованный контроллер |
Учебный пример показывает только разницу между заявлением клиента и анализом временного файла. Он не является тестом антивируса и не подтверждает, что Fileinfo распознаёт любой формат без ошибок. Создайте обычный текст, но передайте ему Content-Type изображения. Сервер должен вывести два разных наблюдения.
\n<?php\n// inspect.php\n$file = $_FILES['avatar'] ?? [];\n\nif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n http_response_code(400);\n exit('Файл не получен');\n}\n\n$finfo = finfo_open(FILEINFO_MIME_TYPE);\n$detected = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;\nif ($finfo) {\n finfo_close($finfo);\n}\n\nheader('Content-Type: text/plain; charset=utf-8');\necho 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;\necho 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;\nprintf '<html>это не фотография</html>' > /tmp/not-an-image.txt\nphp -S 127.0.0.1:8080\ncurl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \\\n http://127.0.0.1:8080/inspect.php\nКоманда намеренно ограничена локальной учебной средой. Ожидаемый вывод зависит от базы MIME-типов Fileinfo, но источник каждого значения остаётся тем же: первое значение пришло в multipart-части, второе вычислил сервер по временному файлу. Если код допуска использует первое значение, отправитель управляет решением.
\nПроверяющая функция не должна сама менять профиль, строить публичный URL или перемещать файл. Её контракт уже: временный файл принят для следующего шага, сервер определил разрешённый MIME-тип, а расширение результата выбрало приложение. В примере разрешены только JPEG и PNG, а лимит размера задан для учебного маршрута. Подставьте свои значения после проверки требований продукта.
\n<?php\n\nfunction inspectImageUpload(array $file): array\n{\n if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n throw new RuntimeException('Загрузка не завершилась');\n }\n\n if (!isset($file['tmp_name'], $file['size'])) {\n throw new RuntimeException('PHP не передал временный файл');\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 return [\n 'temporaryPath' => $file['tmp_name'],\n 'mime' => $mime,\n 'extension' => $allowed[$mime],\n 'bytes' => (int) $file['size'],\n ];\n}\nЭта функция всё ещё не делает файл безопасным во всех смыслах. Она не проверяет право пользователя, CSRF, свободное место, лимит всего HTTP-запроса и содержимое, опасное для внешней библиотеки. Она только закрывает одну границу: сервер не принимает расширение и Content-Type за доказательство типа.
\nПосле проверки не переносите файл под исходным именем. Пользовательское имя можно сохранить как подпись, экранировать при выводе и показать в интерфейсе. Путь должен строиться из серверного ключа. Расширение берите из результата белого списка, а не из $file['name'].
$checked = inspectImageUpload($_FILES['avatar']);\n$key = bin2hex(random_bytes(16)) . '.' . $checked['extension'];\n$target = rtrim($privateDir, DIRECTORY_SEPARATOR)\n . DIRECTORY_SEPARATOR . $key;\n\nif (!move_uploaded_file($checked['temporaryPath'], $target)) {\n throw new RuntimeException('Не удалось сохранить файл');\n}\n\n// В базе: $key, $checked['mime'], $checked['bytes'].\n// Исходное имя хранится отдельно и не участвует в пути.\nmove_uploaded_file() добавляет важную проверку происхождения временного файла, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.
$_FILES['avatar']['error']. При любом значении, кроме UPLOAD_ERR_OK, завершите операцию без чтения и переноса.finfo_file() по временному пути и сравните его с белым списком текущего маршрута.getimagesize() самостоятельным сертификатом безопасности.move_uploaded_file().Текстовый файл с именем photo.jpg должен быть отклонён даже при type=image/jpeg. Файл больше лимита должен быть отклонён до переноса. Ошибка PHP должна завершить маршрут до вызова Fileinfo. Неудачный перенос не должен создавать запись в профиле. Это не косметические ветки. Они показывают, что система не продолжает операцию после нарушения договора.
Для PDF, архивов и офисных документов нужны отдельные маршруты. Им нельзя автоматически присваивать правила картинок. Для документов могут понадобиться антивирусная проверка, распаковка в изолированной среде, запрет исполнения и отдельная политика выдачи. Если приложение преобразует изображение, конвертер получает ещё одну границу доверия: лимит времени, лимит памяти, изолированный каталог и проверку результата.
\nМаршрут готов, если четыре сценария дают предсказуемый результат: корректный JPEG принимается под новым серверным ключом; корректный PNG принимается с расширением из белого списка; текст с именем .jpg и подменённым Content-Type отклоняется; файл, превышающий лимит, не появляется ни в хранилище, ни в базе. Для каждого сценария зафиксируйте HTTP-ответ, код причины, наличие файла и состояние записи. Затем проверьте URL и права чтения. Один удачный upload не подтверждает готовность.
Симптом появляется не в момент выбора файла. Обработчик принимает запрос, видит type=image/jpeg, переносит временный файл и сохраняет имя из формы. Позже в каталоге оказывается текстовый файл с расширением .jpg или файл, который открывает неожиданный обработчик. Цена ошибки — произвольное содержимое в хранилище, публичная раздача чужих данных и лишняя точка для атаки. Если путь лежит внутри веб-корня, ошибка становится доступной по URL. Если имя попадает в путь без нормализации, путь может выйти из каталога назначения.
Ключевая граница: загрузка безопаснее, когда приложение принимает решение по нескольким независимым признакам. Имя, расширение и $_FILES['type'] описывают сообщение клиента. $_FILES['error'] сообщает, как PHP принял часть запроса. finfo_file() классифицирует содержимое временного файла на сервере. Эти сведения нельзя заменить одним условием if ($file['type'] === 'image/jpeg').
Браузер отправляет файл как часть multipart/form-data. В этой части есть имя и Content-Type. Отправитель может быть не браузером: запрос легко собрать через curl или другой HTTP-клиент. Поэтому Content-Type — полезная подсказка, но не доказательство.
После приёма PHP создаёт временный файл и заполняет массив $_FILES. Приложение получает исходное имя, клиентский тип, размер, временный путь и код доставки. Затем оно должно решить, подходит ли файл конкретному сценарию. Для аватара это может быть белый список JPEG и PNG, лимит 2 МБ и диапазон размеров. Для PDF нужны другие правила. Один общий валидатор для всех форматов быстро превращается в набор исключений.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Текст принят как JPEG | Условие читает клиентский type | Сравнить $_FILES['type'] и результат finfo_file() | Убрать клиентский тип из решения о допуске |
| Файл не найден после ошибки формы | Код доставки обработали после чтения временного пути | Проверить $_FILES['error'] до остальных полей | Остановиться при любом коде, кроме UPLOAD_ERR_OK |
| Имя меняет путь сохранения | Путь собран из name или расширения | Отправить имя с пробелами, слешами и повтором | Сгенерировать ключ сервером и хранить имя отдельно |
| Большая картинка расходует ресурсы | Проверяется только MIME-тип | Сравнить размер файла и размеры изображения с лимитами | Отказать до декодирования и переноса |
| Файл доступен по неожиданному URL | Хранилище лежит в веб-корне или выдаёт путь напрямую | Проверить URL, права каталога и маршрут чтения | Хранить вне веб-корня или выдавать через авторизованный контроллер |
Учебный пример показывает только разницу между заявлением клиента и классификацией временного файла. Он не является тестом антивируса и не подтверждает, что Fileinfo распознаёт любой формат без ошибок. Создайте обычный текст, но передайте ему Content-Type изображения. Для строки ASCII ниже Fileinfo обычно возвращает text/plain; окончательный результат зависит от версии magic database, поэтому его нужно увидеть в выводе.
<?php\n// inspect.php\n$file = $_FILES['avatar'] ?? [];\n\nif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n http_response_code(400);\n exit('Файл не получен');\n}\n\n$finfo = new finfo(FILEINFO_MIME_TYPE);\n$detected = $finfo->file($file['tmp_name']);\n\nheader('Content-Type: text/plain; charset=utf-8');\necho 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;\necho 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;\nprintf 'plain text, not an image\\n' > /tmp/not-an-image.txt\n# Терминал 1: каталог с inspect.php\nphp -S 127.0.0.1:8080\n# Терминал 2\ncurl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \\\n http://127.0.0.1:8080/inspect.php\nДля этой команды ожидайте в первой строке image/jpeg, потому что это значение отправлено в multipart-части, а во второй — обычно text/plain, потому что Fileinfo читает временный файл. Если код допуска использует первое значение, отправитель управляет решением. Проверка на сервере отделяет наблюдение от допуска, но сама по себе не заменяет проверку лимитов, прав, изображения и безопасной выдачи.
Проверяющая функция не должна сама менять профиль, строить публичный URL или перемещать файл. Её контракт уже: временный файл принят для следующего шага, сервер определил разрешённый MIME-тип, а расширение результата выбрало приложение. В примере разрешены только JPEG и PNG, а лимит размера задан для учебного маршрута. Подставьте свои значения после проверки требований продукта.
\n<?php\n\nfunction inspectImageUpload(array $file): array\n{\n if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n throw new RuntimeException('Загрузка не завершилась');\n }\n\n if (\n !isset($file['tmp_name'], $file['size'])\n || !is_string($file['tmp_name'])\n || !is_int($file['size'])\n ) {\n throw new RuntimeException('PHP передал неполные данные о файле');\n }\n\n $maxBytes = 2 * 1024 * 1024;\n if ($file['size'] > $maxBytes) {\n throw new RuntimeException('Файл больше 2 МБ');\n }\n\n $finfo = new finfo(FILEINFO_MIME_TYPE);\n $mime = $finfo->file($file['tmp_name']);\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 return [\n 'temporaryPath' => $file['tmp_name'],\n 'mime' => $mime,\n 'extension' => $allowed[$mime],\n 'bytes' => $file['size'],\n ];\n}\nОбъектный вызов new finfo() оставляет пример совместимым с PHP 7.1 и не требует ручного закрытия дескриптора. В PHP 8 Fileinfo перешёл от ресурса к объекту, а finfo_close() с PHP 8.5 помечен устаревшим. Если проект поддерживает старую ветку PHP и использует процедурный API, сверяйте этот участок с документацией своей версии.
Эта функция всё ещё не делает файл безопасным во всех смыслах. Она не проверяет право пользователя, CSRF, свободное место, лимит всего HTTP-запроса и содержимое, опасное для внешней библиотеки. Она только закрывает одну границу: сервер не принимает расширение и Content-Type за доказательство типа. Для изображения отдельно проверьте размеры и способ декодирования; getimagesize() сообщает размеры, но документация PHP прямо не рекомендует использовать его как единственную проверку валидности изображения.
После проверки не переносите файл под исходным именем. Пользовательское имя можно сохранить как подпись, экранировать при выводе и показать в интерфейсе. Путь должен строиться из серверного ключа. Расширение берите из результата белого списка, а не из $file['name'].
$checked = inspectImageUpload($_FILES['avatar']);\n$key = bin2hex(random_bytes(16)) . '.' . $checked['extension'];\n$target = rtrim($privateDir, DIRECTORY_SEPARATOR)\n . DIRECTORY_SEPARATOR . $key;\n\nif (!move_uploaded_file($checked['temporaryPath'], $target)) {\n throw new RuntimeException('Не удалось сохранить файл');\n}\n\n// В базе: $key, $checked['mime'], $checked['bytes'].\n// Исходное имя хранится отдельно и не участвует в пути.\nmove_uploaded_file() проверяет, что источник является загруженным HTTP-файлом, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.
$_FILES['avatar']['error']. При любом значении, кроме UPLOAD_ERR_OK, завершите операцию без чтения и переноса.finfo_file() по временному пути и сравните его с белым списком текущего маршрута.getimagesize() самостоятельным сертификатом безопасности.move_uploaded_file().Текстовый файл с именем photo.jpg должен быть отклонён даже при type=image/jpeg. Файл больше лимита должен быть отклонён до переноса. Ошибка PHP должна завершить маршрут до вызова Fileinfo. Неудачный перенос не должен создавать запись в профиле. Это не косметические ветки. Они показывают, что система не продолжает операцию после нарушения договора.
Для PDF, архивов и офисных документов нужны отдельные маршруты. Им нельзя автоматически присваивать правила картинок. Для документов могут понадобиться антивирусная проверка, распаковка в изолированной среде, запрет исполнения и отдельная политика выдачи. Если приложение преобразует изображение, конвертер получает ещё одну границу доверия: лимит времени, лимит памяти, изолированный каталог и проверку результата.
\nМаршрут готов, если четыре сценария дают предсказуемый результат: корректный JPEG принимается под новым серверным ключом; корректный PNG принимается с расширением из белого списка; текст с именем .jpg и подменённым Content-Type отклоняется; файл, превышающий лимит, не появляется ни в хранилище, ни в базе. Для каждого сценария зафиксируйте HTTP-ответ, код причины, наличие файла и состояние записи. Затем проверьте URL и права чтения. Один удачный upload не подтверждает готовность.