8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 353,
|
||
"slug": "editorial-2018-03-mechanism-safe-uploads",
|
||
"title": "Безопасная загрузка в PHP: почему расширение и MIME из запроса не доказывают тип файла",
|
||
"excerpt": "Разделяем сведения клиента, результат приёма PHP и проверку временного файла. На примере JPEG и PNG показываем порядок проверок, отрицательный путь и границу между допуском файла и его хранением.",
|
||
"contentHtml": "<p>Симптом появляется не в момент выбора файла. Обработчик принимает запрос, видит <code>type=image/jpeg</code>, переносит временный файл и сохраняет имя из формы. Позже в каталоге оказывается текстовый файл с расширением <code>.jpg</code> или файл, который открывает неожиданный обработчик. Цена ошибки — произвольное содержимое в хранилище, публичная раздача чужих данных и лишняя точка для атаки. Если путь лежит внутри веб-корня, ошибка становится доступной по URL. Если имя попадает в путь без нормализации, путь может выйти из каталога назначения.</p>\n<p>Ключевая граница: загрузка безопаснее, когда приложение принимает решение по нескольким независимым признакам. Имя, расширение и <code>$_FILES['type']</code> описывают сообщение клиента. <code>$_FILES['error']</code> сообщает, как PHP принял часть запроса. <code>finfo_file()</code> классифицирует содержимое временного файла на сервере. Эти сведения нельзя заменить одним условием <code>if ($file['type'] === 'image/jpeg')</code>.</p>\n<h2>Как файл проходит через сервер</h2>\n<p>Браузер отправляет файл как часть <code>multipart/form-data</code>. В этой части есть имя и Content-Type. Отправитель может быть не браузером: запрос легко собрать через curl или другой HTTP-клиент. Поэтому Content-Type — полезная подсказка, но не доказательство.</p>\n<p>После приёма PHP создаёт временный файл и заполняет массив <code>$_FILES</code>. Приложение получает исходное имя, клиентский тип, размер, временный путь и код доставки. Затем оно должно решить, подходит ли файл конкретному сценарию. Для аватара это может быть белый список JPEG и PNG, лимит 2 МБ и диапазон размеров. Для PDF нужны другие правила. Один общий валидатор для всех форматов быстро превращается в набор исключений.</p>\n<figure><img src=\"/assets/editorial/2018/php-upload-trust-signals.svg\" alt=\"Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo классифицирует временный файл, приложение применяет собственный белый список\" loading=\"lazy\" /><figcaption>Клиент сообщает имя и Content-Type. PHP сообщает результат доставки. Fileinfo классифицирует временный файл. Допуск определяет приложение по правилам конкретного маршрута.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Разделяем наблюдение и решение</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Текст принят как JPEG</td><td>Условие читает клиентский <code>type</code></td><td>Сравнить <code>$_FILES['type']</code> и результат <code>finfo_file()</code></td><td>Убрать клиентский тип из решения о допуске</td></tr><tr><td>Файл не найден после ошибки формы</td><td>Код доставки обработали после чтения временного пути</td><td>Проверить <code>$_FILES['error']</code> до остальных полей</td><td>Остановиться при любом коде, кроме <code>UPLOAD_ERR_OK</code></td></tr><tr><td>Имя меняет путь сохранения</td><td>Путь собран из <code>name</code> или расширения</td><td>Отправить имя с пробелами, слешами и повтором</td><td>Сгенерировать ключ сервером и хранить имя отдельно</td></tr><tr><td>Большая картинка расходует ресурсы</td><td>Проверяется только MIME-тип</td><td>Сравнить размер файла и размеры изображения с лимитами</td><td>Отказать до декодирования и переноса</td></tr><tr><td>Файл доступен по неожиданному URL</td><td>Хранилище лежит в веб-корне или выдаёт путь напрямую</td><td>Проверить URL, права каталога и маршрут чтения</td><td>Хранить вне веб-корня или выдавать через авторизованный контроллер</td></tr></tbody></table></div>\n<h2>Маленький эксперимент без вредоносного файла</h2>\n<p>Учебный пример показывает только разницу между заявлением клиента и классификацией временного файла. Он не является тестом антивируса и не подтверждает, что Fileinfo распознаёт любой формат без ошибок. Создайте обычный текст, но передайте ему Content-Type изображения. Для строки ASCII ниже Fileinfo обычно возвращает <code>text/plain</code>; окончательный результат зависит от версии magic database, поэтому его нужно увидеть в выводе.</p>\n<pre><code><?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;</code></pre>\n<pre><code>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</code></pre>\n<p>Для этой команды ожидайте в первой строке <code>image/jpeg</code>, потому что это значение отправлено в multipart-части, а во второй — обычно <code>text/plain</code>, потому что Fileinfo читает временный файл. Если код допуска использует первое значение, отправитель управляет решением. Проверка на сервере отделяет наблюдение от допуска, но сама по себе не заменяет проверку лимитов, прав, изображения и безопасной выдачи.</p>\n<h2>Проверка до переноса</h2>\n<p>Проверяющая функция не должна сама менять профиль, строить публичный URL или перемещать файл. Её контракт уже: временный файл принят для следующего шага, сервер определил разрешённый MIME-тип, а расширение результата выбрало приложение. В примере разрешены только JPEG и PNG, а лимит размера задан для учебного маршрута. Подставьте свои значения после проверки требований продукта.</p>\n<pre><code><?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}</code></pre>\n<p>Объектный вызов <code>new finfo()</code> оставляет пример совместимым с PHP 7.1 и не требует ручного закрытия дескриптора. В PHP 8 Fileinfo перешёл от ресурса к объекту, а <code>finfo_close()</code> с PHP 8.5 помечен устаревшим. Если проект поддерживает старую ветку PHP и использует процедурный API, сверяйте этот участок с документацией своей версии.</p>\n<p>Эта функция всё ещё не делает файл безопасным во всех смыслах. Она не проверяет право пользователя, CSRF, свободное место, лимит всего HTTP-запроса и содержимое, опасное для внешней библиотеки. Она только закрывает одну границу: сервер не принимает расширение и Content-Type за доказательство типа. Для изображения отдельно проверьте размеры и способ декодирования; <code>getimagesize()</code> сообщает размеры, но документация PHP прямо не рекомендует использовать его как единственную проверку валидности изображения.</p>\n<h2>Имя и путь должны принадлежать приложению</h2>\n<p>После проверки не переносите файл под исходным именем. Пользовательское имя можно сохранить как подпись, экранировать при выводе и показать в интерфейсе. Путь должен строиться из серверного ключа. Расширение берите из результата белого списка, а не из <code>$file['name']</code>.</p>\n<pre><code>$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// Исходное имя хранится отдельно и не участвует в пути.</code></pre>\n<p><code>move_uploaded_file()</code> проверяет, что источник является загруженным HTTP-файлом, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.</p>\n<h2>Порядок действий</h2>\n<ol><li>Опишите контракт маршрута: какие форматы, максимальный размер, диапазон размеров и кто имеет право загружать файл.</li><li>Проверьте <code>$_FILES['avatar']['error']</code>. При любом значении, кроме <code>UPLOAD_ERR_OK</code>, завершите операцию без чтения и переноса.</li><li>Проверьте прикладной размер и убедитесь, что лимиты веб-сервера и PHP не допускают более крупный запрос.</li><li>Определите MIME-тип через <code>finfo_file()</code> по временному пути и сравните его с белым списком текущего маршрута.</li><li>Если нужны размеры картинки, прочитайте их после проверки типа. Не объявляйте <code>getimagesize()</code> самостоятельным сертификатом безопасности.</li><li>Сгенерируйте ключ приложения, соберите путь из закрытого каталога и серверного расширения, затем вызовите <code>move_uploaded_file()</code>.</li><li>Запишите в базу только после успешного переноса. При отрицательном пути не оставляйте активную ссылку на файл.</li><li>Проверьте выдачу отдельно: публичная картинка может получить прямой URL, приватная должна проходить через проверку владельца.</li></ol>\n<h2>Отрицательный путь и границы применимости</h2>\n<p>Текстовый файл с именем <code>photo.jpg</code> должен быть отклонён даже при <code>type=image/jpeg</code>. Файл больше лимита должен быть отклонён до переноса. Ошибка PHP должна завершить маршрут до вызова Fileinfo. Неудачный перенос не должен создавать запись в профиле. Это не косметические ветки. Они показывают, что система не продолжает операцию после нарушения договора.</p>\n<p>Для PDF, архивов и офисных документов нужны отдельные маршруты. Им нельзя автоматически присваивать правила картинок. Для документов могут понадобиться антивирусная проверка, распаковка в изолированной среде, запрет исполнения и отдельная политика выдачи. Если приложение преобразует изображение, конвертер получает ещё одну границу доверия: лимит времени, лимит памяти, изолированный каталог и проверку результата.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Маршрут готов, если четыре сценария дают предсказуемый результат: корректный JPEG принимается под новым серверным ключом; корректный PNG принимается с расширением из белого списка; текст с именем <code>.jpg</code> и подменённым Content-Type отклоняется; файл, превышающий лимит, не появляется ни в хранилище, ни в базе. Для каждого сценария зафиксируйте HTTP-ответ, код причины, наличие файла и состояние записи. Затем проверьте URL и права чтения. Один удачный upload не подтверждает готовность.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.php.net/manual/en/features.file-upload.post-method.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: POST-загрузка и поля $_FILES</a></li><li><a href=\"https://www.php.net/manual/en/function.finfo-file.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: finfo_file()</a></li><li><a href=\"https://www.php.net/manual/en/function.finfo-close.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: finfo_close() и версии PHP</a></li><li><a href=\"https://www.php.net/manual/en/function.getimagesize.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: getimagesize() и ограничение его проверки</a></li><li><a href=\"https://www.php.net/manual/en/function.move-uploaded-file.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: move_uploaded_file()</a></li><li><a href=\"https://www.rfc-editor.org/rfc/rfc7578\" target=\"_blank\" rel=\"noopener\">RFC 7578: multipart/form-data</a></li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP: File Upload Cheat Sheet</a></li></ul>"
|
||
}
|