8 lines
17 KiB
JSON
8 lines
17 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 изображения. Сервер должен вывести два разных наблюдения.</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 = 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;</code></pre>\n<pre><code>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</code></pre>\n<p>Команда намеренно ограничена локальной учебной средой. Ожидаемый вывод зависит от базы MIME-типов Fileinfo, но источник каждого значения остаётся тем же: первое значение пришло в multipart-части, второе вычислил сервер по временному файлу. Если код допуска использует первое значение, отправитель управляет решением.</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 (!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}</code></pre>\n<p>Эта функция всё ещё не делает файл безопасным во всех смыслах. Она не проверяет право пользователя, CSRF, свободное место, лимит всего HTTP-запроса и содержимое, опасное для внешней библиотеки. Она только закрывает одну границу: сервер не принимает расширение и Content-Type за доказательство типа.</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> добавляет важную проверку происхождения временного файла, но не решает вопросы типа, лимита или доступа. Вызывайте его после прикладных проверок. Если перенос не удался, не создавайте ссылку в профиле. Если запись в базе не удалась после переноса, удалите сиротский файл или отправьте операцию в отдельный надёжный процесс уборки.</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.errors.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: коды ошибок загрузки</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.rfc-editor.org/rfc/rfc7578\" target=\"_blank\" rel=\"noopener\">RFC 7578: multipart/form-data</a></li></ul>"
|
||
}
|