From ec2a3de9c87eb9bb617c3cd413fd877ab701e179 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:17:08 +0300 Subject: [PATCH] Editorial: refine PHP upload mechanism article 353 --- editorial/agent-rewrites/353.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. Если имя попадает в путь без нормализации, запрос может повредить чужой файл или запись.

\n

Тезис простой: загрузка безопаснее, когда приложение принимает решение по нескольким независимым признакам. Имя, расширение и $_FILES['type'] описывают сообщение клиента. $_FILES['error'] сообщает, как PHP принял часть запроса. finfo_file() исследует временный файл на сервере. Эти сведения нельзя заменить одним условием if ($file['type'] === 'image/jpeg').

\n

Как файл проходит через сервер

\n

Браузер отправляет файл как часть multipart/form-data. В этой части есть имя и Content-Type. Отправитель может быть не браузером: запрос легко собрать через curl или другой HTTP-клиент. Поэтому Content-Type — полезная подсказка, но не доказательство.

\n

После приёма PHP создаёт временный файл и заполняет массив $_FILES. Приложение получает исходное имя, клиентский тип, размер, временный путь и код доставки. Затем оно должно решить, подходит ли файл конкретному сценарию. Для аватара это может быть белый список JPEG и PNG, лимит 2 МБ и диапазон размеров. Для PDF нужны другие правила. Один общий валидатор для всех форматов быстро превращается в набор исключений.

\n
\"Схема
Клиент сообщает имя и Content-Type. PHP сообщает результат доставки. Fileinfo читает временный файл. Допуск определяет приложение по правилам конкретного маршрута.
\n

Симптом → причина → проверка → действие

\n
Разделяем наблюдение и решение
СимптомПричинаПроверкаДействие
Текст принят как JPEGУсловие читает клиентский typeСравнить $_FILES['type'] и результат finfo_file()Убрать клиентский тип из решения о допуске
Файл не найден после ошибки формыКод доставки обработали после чтения временного путиПроверить $_FILES['error'] до остальных полейОстановиться при любом коде, кроме UPLOAD_ERR_OK
Имя меняет путь сохраненияПуть собран из name или расширенияОтправить имя с пробелами, слешами и повторомСгенерировать ключ сервером и хранить имя отдельно
Большая картинка расходует ресурсыПроверяется только MIME-типСравнить размер файла и размеры изображения с лимитамиОтказать до декодирования и переноса
Файл доступен по неожиданному URLХранилище лежит в веб-корне или выдаёт путь напрямуюПроверить URL, права каталога и маршрут чтенияХранить вне веб-корня или выдавать через авторизованный контроллер
\n

Маленький эксперимент без вредоносного файла

\n

Учебный пример показывает только разницу между заявлением клиента и анализом временного файла. Он не является тестом антивируса и не подтверждает, что 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;
\n
printf '<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

Проверка до переноса

\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

Имя и путь должны принадлежать приложению

\n

После проверки не переносите файл под исходным именем. Пользовательское имя можно сохранить как подпись, экранировать при выводе и показать в интерфейсе. Путь должен строиться из серверного ключа. Расширение берите из результата белого списка, а не из $file['name'].

\n
$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// Исходное имя хранится отдельно и не участвует в пути.
\n

move_uploaded_file() добавляет важную проверку происхождения временного файла, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.

\n

Порядок действий

\n
  1. Опишите контракт маршрута: какие форматы, максимальный размер, диапазон размеров и кто имеет право загружать файл.
  2. Проверьте $_FILES['avatar']['error']. При любом значении, кроме UPLOAD_ERR_OK, завершите операцию без чтения и переноса.
  3. Проверьте прикладной размер и убедитесь, что лимиты веб-сервера и PHP не допускают более крупный запрос.
  4. Определите MIME-тип через finfo_file() по временному пути и сравните его с белым списком текущего маршрута.
  5. Если нужны размеры картинки, прочитайте их после проверки типа. Не объявляйте getimagesize() самостоятельным сертификатом безопасности.
  6. Сгенерируйте ключ приложения, соберите путь из закрытого каталога и серверного расширения, затем вызовите move_uploaded_file().
  7. Запишите в базу только после успешного переноса. При отрицательном пути не оставляйте активную ссылку на файл.
  8. Проверьте выдачу отдельно: публичная картинка может получить прямой URL, приватная должна проходить через проверку владельца.
\n

Отрицательный путь и границы применимости

\n

Текстовый файл с именем photo.jpg должен быть отклонён даже при type=image/jpeg. Файл больше лимита должен быть отклонён до переноса. Ошибка PHP должна завершить маршрут до вызова Fileinfo. Неудачный перенос не должен создавать запись в профиле. Это не косметические ветки. Они показывают, что система не продолжает операцию после нарушения договора.

\n

Для PDF, архивов и офисных документов нужны отдельные маршруты. Им нельзя автоматически присваивать правила картинок. Для документов могут понадобиться антивирусная проверка, распаковка в изолированной среде, запрет исполнения и отдельная политика выдачи. Если приложение преобразует изображение, конвертер получает ещё одну границу доверия: лимит времени, лимит памяти, изолированный каталог и проверку результата.

\n

Проверяемый критерий готовности

\n

Маршрут готов, если четыре сценария дают предсказуемый результат: корректный JPEG принимается под новым серверным ключом; корректный PNG принимается с расширением из белого списка; текст с именем .jpg и подменённым Content-Type отклоняется; файл, превышающий лимит, не появляется ни в хранилище, ни в базе. Для каждого сценария зафиксируйте HTTP-ответ, код причины, наличие файла и состояние записи. Затем проверьте URL и права чтения. Один удачный upload не подтверждает готовность.

\n

Проверяемые источники

" + "contentHtml": "

Симптом появляется не в момент выбора файла. Обработчик принимает запрос, видит type=image/jpeg, переносит временный файл и сохраняет имя из формы. Позже в каталоге оказывается текстовый файл с расширением .jpg или файл, который открывает неожиданный обработчик. Цена ошибки — произвольное содержимое в хранилище, публичная раздача чужих данных и лишняя точка для атаки. Если путь лежит внутри веб-корня, ошибка становится доступной по URL. Если имя попадает в путь без нормализации, путь может выйти из каталога назначения.

\n

Ключевая граница: загрузка безопаснее, когда приложение принимает решение по нескольким независимым признакам. Имя, расширение и $_FILES['type'] описывают сообщение клиента. $_FILES['error'] сообщает, как PHP принял часть запроса. finfo_file() классифицирует содержимое временного файла на сервере. Эти сведения нельзя заменить одним условием if ($file['type'] === 'image/jpeg').

\n

Как файл проходит через сервер

\n

Браузер отправляет файл как часть multipart/form-data. В этой части есть имя и Content-Type. Отправитель может быть не браузером: запрос легко собрать через curl или другой HTTP-клиент. Поэтому Content-Type — полезная подсказка, но не доказательство.

\n

После приёма PHP создаёт временный файл и заполняет массив $_FILES. Приложение получает исходное имя, клиентский тип, размер, временный путь и код доставки. Затем оно должно решить, подходит ли файл конкретному сценарию. Для аватара это может быть белый список JPEG и PNG, лимит 2 МБ и диапазон размеров. Для PDF нужны другие правила. Один общий валидатор для всех форматов быстро превращается в набор исключений.

\n
\"Схема
Клиент сообщает имя и Content-Type. PHP сообщает результат доставки. Fileinfo классифицирует временный файл. Допуск определяет приложение по правилам конкретного маршрута.
\n

Симптом → причина → проверка → действие

\n
Разделяем наблюдение и решение
СимптомПричинаПроверкаДействие
Текст принят как JPEGУсловие читает клиентский typeСравнить $_FILES['type'] и результат finfo_file()Убрать клиентский тип из решения о допуске
Файл не найден после ошибки формыКод доставки обработали после чтения временного путиПроверить $_FILES['error'] до остальных полейОстановиться при любом коде, кроме UPLOAD_ERR_OK
Имя меняет путь сохраненияПуть собран из name или расширенияОтправить имя с пробелами, слешами и повторомСгенерировать ключ сервером и хранить имя отдельно
Большая картинка расходует ресурсыПроверяется только MIME-типСравнить размер файла и размеры изображения с лимитамиОтказать до декодирования и переноса
Файл доступен по неожиданному URLХранилище лежит в веб-корне или выдаёт путь напрямуюПроверить URL, права каталога и маршрут чтенияХранить вне веб-корня или выдавать через авторизованный контроллер
\n

Маленький эксперимент без вредоносного файла

\n

Учебный пример показывает только разницу между заявлением клиента и классификацией временного файла. Он не является тестом антивируса и не подтверждает, что Fileinfo распознаёт любой формат без ошибок. Создайте обычный текст, но передайте ему Content-Type изображения. Для строки ASCII ниже Fileinfo обычно возвращает text/plain; окончательный результат зависит от версии magic database, поэтому его нужно увидеть в выводе.

\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 = 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;
\n
printf '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 читает временный файл. Если код допуска использует первое значение, отправитель управляет решением. Проверка на сервере отделяет наблюдение от допуска, но сама по себе не заменяет проверку лимитов, прав, изображения и безопасной выдачи.

\n

Проверка до переноса

\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 (\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, сверяйте этот участок с документацией своей версии.

\n

Эта функция всё ещё не делает файл безопасным во всех смыслах. Она не проверяет право пользователя, CSRF, свободное место, лимит всего HTTP-запроса и содержимое, опасное для внешней библиотеки. Она только закрывает одну границу: сервер не принимает расширение и Content-Type за доказательство типа. Для изображения отдельно проверьте размеры и способ декодирования; getimagesize() сообщает размеры, но документация PHP прямо не рекомендует использовать его как единственную проверку валидности изображения.

\n

Имя и путь должны принадлежать приложению

\n

После проверки не переносите файл под исходным именем. Пользовательское имя можно сохранить как подпись, экранировать при выводе и показать в интерфейсе. Путь должен строиться из серверного ключа. Расширение берите из результата белого списка, а не из $file['name'].

\n
$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// Исходное имя хранится отдельно и не участвует в пути.
\n

move_uploaded_file() проверяет, что источник является загруженным HTTP-файлом, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.

\n

Порядок действий

\n
  1. Опишите контракт маршрута: какие форматы, максимальный размер, диапазон размеров и кто имеет право загружать файл.
  2. Проверьте $_FILES['avatar']['error']. При любом значении, кроме UPLOAD_ERR_OK, завершите операцию без чтения и переноса.
  3. Проверьте прикладной размер и убедитесь, что лимиты веб-сервера и PHP не допускают более крупный запрос.
  4. Определите MIME-тип через finfo_file() по временному пути и сравните его с белым списком текущего маршрута.
  5. Если нужны размеры картинки, прочитайте их после проверки типа. Не объявляйте getimagesize() самостоятельным сертификатом безопасности.
  6. Сгенерируйте ключ приложения, соберите путь из закрытого каталога и серверного расширения, затем вызовите move_uploaded_file().
  7. Запишите в базу только после успешного переноса. При отрицательном пути не оставляйте активную ссылку на файл.
  8. Проверьте выдачу отдельно: публичная картинка может получить прямой URL, приватная должна проходить через проверку владельца.
\n

Отрицательный путь и границы применимости

\n

Текстовый файл с именем photo.jpg должен быть отклонён даже при type=image/jpeg. Файл больше лимита должен быть отклонён до переноса. Ошибка PHP должна завершить маршрут до вызова Fileinfo. Неудачный перенос не должен создавать запись в профиле. Это не косметические ветки. Они показывают, что система не продолжает операцию после нарушения договора.

\n

Для PDF, архивов и офисных документов нужны отдельные маршруты. Им нельзя автоматически присваивать правила картинок. Для документов могут понадобиться антивирусная проверка, распаковка в изолированной среде, запрет исполнения и отдельная политика выдачи. Если приложение преобразует изображение, конвертер получает ещё одну границу доверия: лимит времени, лимит памяти, изолированный каталог и проверку результата.

\n

Проверяемый критерий готовности

\n

Маршрут готов, если четыре сценария дают предсказуемый результат: корректный JPEG принимается под новым серверным ключом; корректный PNG принимается с расширением из белого списка; текст с именем .jpg и подменённым Content-Type отклоняется; файл, превышающий лимит, не появляется ни в хранилище, ни в базе. Для каждого сценария зафиксируйте HTTP-ответ, код причины, наличие файла и состояние записи. Затем проверьте URL и права чтения. Один удачный upload не подтверждает готовность.

\n

Проверяемые источники

" }