Files
progcode/editorial/agent-rewrites/354.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 354,
"slug": "editorial-2018-03-practice-safe-uploads",
"title": "PHP: безопасная загрузка аватара без доверия к имени файла",
"excerpt": "Как принять JPEG или PNG от пользователя, проверить временный файл на сервере, сохранить его под своим ключом и не открыть приложению лишний путь к выполнению кода.",
"contentHtml": "<p>Симптом появляется не в форме. Кнопка сообщает «файл сохранён», но в каталоге загрузок лежит имя из запроса, каталог доступен из веб-корня, а сервер проверил только суффикс <code>.jpg</code>. Иногда ошибка видна сразу: приложение сохраняет текстовый файл с клиентским <code>Content-Type: image/jpeg</code>. Иногда она ждёт следующего шага: другой обработчик отдаёт загруженный файл как ресурс или передаёт его конвертеру.</p>\n<p>Цена ошибки — не только испорченный аватар. Пользователь может перезаписать чужой файл, получить неожиданный контент по предсказуемому URL или передать файл компоненту, который не рассчитан на такой вход. Само наличие <code>move_uploaded_file()</code> проблему не решает. Функция подтверждает, что исходный путь связан с HTTP-загрузкой PHP. Она не подтверждает, что файл подходит бизнес-правилу.</p>\n<p><strong>Тезис:</strong> безопасная загрузка начинается с узкого контракта. Сервер принимает один файл, проверяет результат доставки, размер и содержимое временного файла, выбирает расширение сам, переносит файл в хранилище вне веб-корня и сохраняет в профиле только ключ приложения. Имя, расширение и MIME-тип из формы остаются данными клиента.</p>\n<h2>Как проходит файл</h2>\n<p>Браузер отправляет multipart-запрос. PHP принимает его и создаёт временный файл. В <code>$_FILES['avatar']</code> появляются имя, тип, размер, временный путь и код ошибки. Первые три значения полезны для интерфейса и диагностики, но не дают серверу достаточного основания разрешить сохранение.</p>\n<p>Обработчик должен разделить четыре решения. Сначала он проверяет, что PHP действительно принял часть запроса. Затем ограничивает размер. После этого определяет MIME-тип по временному файлу и сверяет его с белым списком. Для изображения он отдельно читает размеры. Только после всех проверок приложение создаёт имя и переносит файл.</p>\n<figure><img src='/assets/editorial/2018/php-upload-avatar-contract.svg' alt='Путь файла аватара: браузер отправляет multipart-часть, PHP создаёт временный файл, сервер проверяет его и переносит в закрытое хранилище под сгенерированным ключом.' /><figcaption>Проверки выполняются до переноса. В хранилище попадает ключ приложения, а не имя из формы. Asset: <code>/assets/editorial/2018/php-upload-avatar-contract.svg</code>.</figcaption></figure>\n<p>В примере ниже договор относится только к аватару. Он принимает один JPEG или PNG размером до 2 МБ и с шириной и высотой от 64 до 3000 пикселей. Эти числа — учебные ограничения, а не универсальная норма. Продукт должен выбрать их по своему интерфейсу, лимитам PHP и доступному диску.</p>\n<h2>Обработчик с явными границами</h2>\n<p>Функция получает массив одного файла и абсолютный путь к закрытому каталогу. Она не использует <code>$file['name']</code> при построении пути. Поле <code>$file['type']</code> тоже не участвует в решении: его передаёт клиент. Серверный MIME-тип получается из временного файла через Fileinfo.</p>\n<pre><code>&lt;?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'] &gt; $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' =&gt; 'jpg',\n 'image/png' =&gt; '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 &lt; 64 || $height &lt; 64 || $width &gt; 3000 || $height &gt; 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' =&gt; $storageKey,\n 'mime' =&gt; $mime,\n 'width' =&gt; $width,\n 'height' =&gt; $height,\n ];\n}</code></pre>\n<p>В реальном приложении каталог должен существовать заранее, принадлежать ожидаемому пользователю процесса и быть недоступен как исполняемая директория веб-сервера. Приложение должно проверить права текущего пользователя до записи и обработать исключение на уровне HTTP-ответа. Код выше — учебный пример порядка проверок, а не готовый пакет для любого типа файла.</p>\n<p>Сгенерированный ключ не содержит разделителей пути из имени клиента и не обязан совпадать с ним. Если ключ строится случайно, одинаковые имена вроде <code>avatar.jpg</code> не создают коллизии. Расширение берётся из принятого MIME-типа, а не копируется из <code>$file['name']</code>.</p>\n<p>Если возможна замена аватара, сначала сохраните новый файл, затем атомарно поменяйте ссылку в профиле и только после успешной транзакции удалите старый объект. Иначе ошибка записи профиля оставит пользователя без прежнего изображения. Это отдельный жизненный цикл; функция выше отвечает только за приём и перенос.</p>\n<h2>Симптомы и проверки</h2>\n<div class='table-scroll'><table><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Сохраняется файл с любым расширением</td><td>Путь строится из <code>name</code></td><td>Отправить <code>../../other.jpg</code> и имя с пробелами</td><td>Игнорировать имя; создать ключ приложением</td></tr><tr><td>Текст проходит как изображение</td><td>Проверяется только <code>type</code> или суффикс</td><td>Послать текстовый файл с именем <code>photo.jpg</code></td><td>Определить тип временного файла и применить белый список</td></tr><tr><td>Большой файл падает позже</td><td>Лимит проверяет только форма</td><td>Отправить файл больше договорного размера</td><td>Отклонить до чтения изображения и проверить лимиты PHP</td></tr><tr><td>Файл доступен по угадываемому URL</td><td>Хранилище лежит в веб-корне</td><td>Открыть URL каталога без UI приложения</td><td>Перенести объект за веб-корень; выдавать его через контроллер</td></tr><tr><td>Профиль ссылается на отсутствующий объект</td><td>Ссылка записана раньше переноса или без проверки результата</td><td>Сымитировать ошибку прав на каталог</td><td>Сохранять ссылку только после успешного переноса и проверять возврат</td></tr></tbody></table></div>\n<p>Таблица описывает проверки маршрута, а не доказательство полной безопасности сервиса. Другой endpoint всё ещё может принимать тот же файл без этой функции. Правило должно быть общим для всех входов или явно ограниченным одним маршрутом.</p>\n<h2>Форма не заменяет сервер</h2>\n<pre><code>&lt;form method='post' enctype='multipart/form-data' action='/profile/avatar.php'&gt;\n &lt;input type='file' name='avatar' accept='image/jpeg,image/png' required&gt;\n &lt;button type='submit'&gt;Сохранить аватар&lt;/button&gt;\n&lt;/form&gt;</code></pre>\n<p><code>enctype='multipart/form-data'</code> нужен для передачи файла. <code>accept</code> ограничивает подсказку выбора в браузере. Оба атрибута не являются границей доверия. Запрос можно собрать вручную через curl или другой HTTP-клиент. Сервер должен одинаково обработать такой запрос и обычную отправку формы.</p>\n<h2>Порядок действий</h2>\n<ol><li>Определить контракт маршрута: одно поле, список типов, максимальный размер, диапазон размеров изображения и правило доступа.</li><li>Создать закрытое хранилище вне веб-корня и проверить права процесса PHP на запись без разрешения на выполнение загруженных файлов.</li><li>Проверить обязательные поля и код <code>UPLOAD_ERR_OK</code>. При ошибке завершить запрос до работы с временным путём.</li><li>Сравнить размер с лимитом приложения и сверить его с <code>upload_max_filesize</code> и <code>post_max_size</code>.</li><li>Определить MIME-тип по временному файлу через Fileinfo и сопоставить его с точным белым списком.</li><li>Прочитать размеры изображения и отклонить файл вне диапазона. Не использовать <code>getimagesize()</code> как единственный security-валидатор.</li><li>Сгенерировать ключ, перенести файл через <code>move_uploaded_file()</code> и проверить результат.</li><li>Сохранить в профиле ключ, серверный MIME-тип и размеры только после успешного переноса.</li><li>Проверить успешный JPEG, успешный PNG, текст с расширением <code>.jpg</code>, файл больше лимита и ошибку записи в хранилище.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Этот пример не сканирует вредоносное содержимое, не устраняет CSRF и не делает безопасную перекодировку изображения. Он не защищает от исчерпания диска, чрезмерного числа запросов или особенностей библиотек, которые будут читать файл дальше. Для документов, архивов и видео нужны отдельные правила: другой белый список, лимиты распаковки или декодирования и отдельный способ выдачи.</p>\n<p>Даже для JPEG и PNG MIME-проверка не даёт права бездумно отправлять файл в ImageMagick, браузер или другой сервис. Каждый следующий потребитель должен иметь собственный лимит и обработку ошибки. Если изображение публичное, выдавайте его через заранее определённый URL и правильный заголовок типа. Если доступ зависит от пользователя, сначала проверяйте право, а затем отдавайте объект из закрытого хранилища.</p>\n<p>При любой проверочной ошибке обработчик не переносит временный файл, не создаёт ссылку в профиле и не сообщает клиенту внутренний путь. Логируйте код отказа и идентификатор операции, но не исходное имя без необходимости. Пользователю достаточно сообщения «файл не принят» и понятного объяснения лимита.</p>\n<h2>Критерий готовности</h2>\n<p>Маршрут готов для этого учебного контракта, если пять проверок дают наблюдаемый результат: допустимые JPEG и PNG появляются в закрытом хранилище под сгенерированными ключами; текстовый файл с расширением <code>.jpg</code> отклоняется; слишком большой файл отклоняется до переноса; ошибка прав не создаёт ссылку в профиле; прямой URL каталога не раскрывает объект. Проверку нужно выполнить на целевой конфигурации PHP и веб-сервера. Критерий подтверждает маршрут, но не заявляет, что приложение в целом безопасно для любых файлов.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.php.net/manual/en/function.move-uploaded-file.php' target='_blank' rel='noopener noreferrer'>PHP Manual: move_uploaded_file</a> — описывает проверку происхождения загруженного файла и результат переноса. Не заменяет проверку содержимого и политики хранения.</li><li><a href='https://www.php.net/manual/en/function.finfo-file.php' target='_blank' rel='noopener noreferrer'>PHP Manual: finfo_file</a> — описывает получение сведений о файле по его содержимому. Белый список и лимиты остаются правилом приложения.</li><li><a href='https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html' target='_blank' rel='noopener noreferrer'>OWASP File Upload Cheat Sheet</a> — рекомендует ограничивать тип и размер, не доверять Content-Type, менять имя и проверять права пользователя. Это общий контрольный список, а не результат проверки данного проекта.</li></ul>"
}