Files
progcode/editorial/agent-rewrites/354.json
T

8 lines
14 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>move_uploaded_file</code>. Ошибка становится заметна позже: каталог <code>uploads</code> оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к <code>.jpg</code>. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.</p>\n<p>Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.</p>\n<h2>Сначала договоримся о результате</h2>\n<p>Форма передаёт один файл <code>avatar</code>. Мы принимаем не более 2 МБ, только <code>image/jpeg</code> и <code>image/png</code>, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например <code>7f4a...c2.png</code>. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.</p>\n<figure><img src=\"/assets/editorial/2018/php-upload-avatar-contract.svg\" alt=\"Путь файла аватара: браузер передаёт multipart-часть, PHP создаёт временный файл, код проверяет его и переносит в закрытое хранилище под сгенерированным ключом.\" /><figcaption>Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.</figcaption></figure>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Проверка</th><th scope=\"col\">Что она отвечает</th><th scope=\"col\">Что делаем при отказе</th></tr></thead><tbody><tr><td><code>UPLOAD_ERR_OK</code></td><td>PHP полностью принял часть запроса</td><td>Не читаем временный путь, показываем понятную ошибку загрузки</td></tr><tr><td>Лимит 2 МБ</td><td>Файл укладывается в договор аватара</td><td>Не переносим файл и не пытаемся уменьшать его вслепую</td></tr><tr><td><code>finfo_file</code></td><td>Какой MIME-тип определён по временному файлу</td><td>Отклоняем тип, которого нет в белом списке</td></tr><tr><td>Размеры изображения</td><td>Подходит ли картинка для интерфейса</td><td>Отклоняем слишком маленькое или слишком большое изображение</td></tr><tr><td>Сгенерированный ключ</td><td>Куда именно будет записан файл</td><td>Никогда не составляем путь из исходного имени</td></tr></tbody></table></div>\n<h2>Обработчик без скрытого шага</h2>\n<p>Проверка <code>$_FILES[\"avatar\"][\"error\"]</code> должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле <code>type</code> из <code>$_FILES</code> здесь намеренно не используется: его прислал клиент.</p>\n<pre><code>&lt;?php\n\nfunction storeAvatar(array $file, string $privateDir): array\n{\n if (!isset($file[&#039;error&#039;], $file[&#039;tmp_name&#039;], $file[&#039;size&#039;])) {\n throw new RuntimeException(&#039;Поле avatar передано в неверном формате&#039;);\n }\n\n if ($file[&#039;error&#039;] !== UPLOAD_ERR_OK) {\n throw new RuntimeException(&#039;PHP не принял файл: код &#039; . $file[&#039;error&#039;]);\n }\n\n $maxBytes = 2 * 1024 * 1024;\n if ((int)$file[&#039;size&#039;] &gt; $maxBytes) {\n throw new RuntimeException(&#039;Аватар больше 2 МБ&#039;);\n }\n\n $finfo = finfo_open(FILEINFO_MIME_TYPE);\n if ($finfo === false) {\n throw new RuntimeException(&#039;Расширение Fileinfo недоступно&#039;);\n }\n\n $mime = finfo_file($finfo, $file[&#039;tmp_name&#039;]);\n finfo_close($finfo);\n\n $allowed = [\n &#039;image/jpeg&#039; =&gt; &#039;jpg&#039;,\n &#039;image/png&#039; =&gt; &#039;png&#039;,\n ];\n\n if (!is_string($mime) || !isset($allowed[$mime])) {\n throw new RuntimeException(&#039;Нужен JPEG или PNG&#039;);\n }\n\n $size = getimagesize($file[&#039;tmp_name&#039;]);\n if ($size === false) {\n throw new RuntimeException(&#039;Не удалось прочитать размеры изображения&#039;);\n }\n\n list($width, $height) = $size;\n if ($width &lt; 64 || $height &lt; 64 || $width &gt; 3000 || $height &gt; 3000) {\n throw new RuntimeException(&#039;Размеры изображения вне допустимого диапазона&#039;);\n }\n\n $storageKey = bin2hex(random_bytes(16)) . &#039;.&#039; . $allowed[$mime];\n $target = rtrim($privateDir, DIRECTORY_SEPARATOR)\n . DIRECTORY_SEPARATOR . $storageKey;\n\n if (!move_uploaded_file($file[&#039;tmp_name&#039;], $target)) {\n throw new RuntimeException(&#039;Не удалось сохранить аватар&#039;);\n }\n\n return [\n &#039;storageKey&#039; =&gt; $storageKey,\n &#039;mime&#039; =&gt; $mime,\n &#039;width&#039; =&gt; $width,\n &#039;height&#039; =&gt; $height,\n ];\n}</code></pre>\n<h2>Почему порядок проверок важнее набора функций</h2>\n<p>У <code>move_uploaded_file</code> есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам. Вызов также может перезаписать существующий destination, поэтому случайный ключ не следует считать заменой атомарного создания объекта в хранилище с высокой конкуренцией.</p>\n<p>Вызов <code>getimagesize</code> нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.</p>\n<h2>Минимальная форма и проверка руками</h2>\n<pre><code>&lt;form method=&quot;post&quot; enctype=&quot;multipart/form-data&quot; action=&quot;/profile/avatar.php&quot;&gt;\n &lt;input type=&quot;file&quot; name=&quot;avatar&quot; accept=&quot;image/jpeg,image/png&quot; required&gt;\n &lt;button type=&quot;submit&quot;&gt;Сохранить аватар&lt;/button&gt;\n&lt;/form&gt;</code></pre>\n<p>Атрибут <code>accept</code> помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением <code>.jpg</code> и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.</p>\n<p>Отрицательный сценарий можно повторить без вредного файла. Создайте обычный текст, назовите его как JPEG и вручную передайте клиентский MIME-тип:</p>\n<pre><code>mkdir -p fixtures\nprintf &#039;not an image\\n&#039; &gt; fixtures/not-an-image.jpg\n\ncurl -i -X POST \\\n -F &#039;avatar=@fixtures/not-an-image.jpg;type=image/jpeg&#039; \\\n https://localhost/profile/avatar.php</code></pre>\n<p>Ожидаемый результат задаёт контракт endpoint: запрос отклонён, постоянный объект и ссылка в профиле не созданы. Конкретный HTTP-статус выбирает сервис, например <code>400</code> или <code>415</code>. Значение Fileinfo может зависеть от системной magic-базы, поэтому на целевом стенде нужно зафиксировать фактический ответ, а не подставлять его заранее.</p>\n<h2>Порядок запуска</h2>\n<ol><li>Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.</li><li>Подключить форму с <code>multipart/form-data</code> и передать <code>$_FILES[\"avatar\"]</code> в функцию.</li><li>После успешного вызова сохранить только <code>storageKey</code>, MIME-тип и размеры рядом с пользователем.</li><li>Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.</li><li>Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.</li></ol>\n<h2>Граница этого примера</h2>\n<p>Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.</p>\n<h2>Проверяемые источники</h2>\n<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.is-uploaded-file.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: is_uploaded_file</a> — проверяет, что временный путь относится к HTTP-загрузке PHP.</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.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.getimagesize.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: getimagesize и его ограничение как валидатора</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>"
}