diff --git a/editorial/agent-rewrites/354.json b/editorial/agent-rewrites/354.json index 33a108d..ca864f5 100644 --- a/editorial/agent-rewrites/354.json +++ b/editorial/agent-rewrites/354.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-03-practice-safe-uploads", "title": "PHP: безопасная загрузка аватара без доверия к имени файла", "excerpt": "Как принять JPEG или PNG от пользователя, проверить временный файл на сервере, сохранить его под своим ключом и не открыть приложению лишний путь к выполнению кода.", - "contentHtml": "

Симптом появляется не в форме. Кнопка сообщает «файл сохранён», но в каталоге загрузок лежит имя из запроса, каталог доступен из веб-корня, а сервер проверил только суффикс .jpg. Иногда ошибка видна сразу: приложение сохраняет текстовый файл с клиентским Content-Type: image/jpeg. Иногда она ждёт следующего шага: другой обработчик отдаёт загруженный файл как ресурс или передаёт его конвертеру.

\n

Цена ошибки — не только испорченный аватар. Пользователь может перезаписать чужой файл, получить неожиданный контент по предсказуемому URL или передать файл компоненту, который не рассчитан на такой вход. Само наличие move_uploaded_file() проблему не решает. Функция подтверждает, что исходный путь связан с HTTP-загрузкой PHP. Она не подтверждает, что файл подходит бизнес-правилу.

\n

Тезис: безопасная загрузка начинается с узкого контракта. Сервер принимает один файл, проверяет результат доставки, размер и содержимое временного файла, выбирает расширение сам, переносит файл в хранилище вне веб-корня и сохраняет в профиле только ключ приложения. Имя, расширение и MIME-тип из формы остаются данными клиента.

\n

Как проходит файл

\n

Браузер отправляет multipart-запрос. PHP принимает его и создаёт временный файл. В $_FILES['avatar'] появляются имя, тип, размер, временный путь и код ошибки. Первые три значения полезны для интерфейса и диагностики, но не дают серверу достаточного основания разрешить сохранение.

\n

Обработчик должен разделить четыре решения. Сначала он проверяет, что PHP действительно принял часть запроса. Затем ограничивает размер. После этого определяет MIME-тип по временному файлу и сверяет его с белым списком. Для изображения он отдельно читает размеры. Только после всех проверок приложение создаёт имя и переносит файл.

\n
Путь файла аватара: браузер отправляет multipart-часть, PHP создаёт временный файл, сервер проверяет его и переносит в закрытое хранилище под сгенерированным ключом.
Проверки выполняются до переноса. В хранилище попадает ключ приложения, а не имя из формы. Asset: /assets/editorial/2018/php-upload-avatar-contract.svg.
\n

В примере ниже договор относится только к аватару. Он принимает один JPEG или PNG размером до 2 МБ и с шириной и высотой от 64 до 3000 пикселей. Эти числа — учебные ограничения, а не универсальная норма. Продукт должен выбрать их по своему интерфейсу, лимитам PHP и доступному диску.

\n

Обработчик с явными границами

\n

Функция получает массив одного файла и абсолютный путь к закрытому каталогу. Она не использует $file['name'] при построении пути. Поле $file['type'] тоже не участвует в решении: его передаёт клиент. Серверный MIME-тип получается из временного файла через Fileinfo.

\n
<?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'] > $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    $imageSize = getimagesize($file['tmp_name']);\n    if ($imageSize === false) {\n        throw new RuntimeException('Не удалось прочитать изображение');\n    }\n\n    [$width, $height] = $imageSize;\n    if ($width < 64 || $height < 64 || $width > 3000 || $height > 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' => $storageKey,\n        'mime' => $mime,\n        'width' => $width,\n        'height' => $height,\n    ];\n}
\n

В реальном приложении каталог должен существовать заранее, принадлежать ожидаемому пользователю процесса и быть недоступен как исполняемая директория веб-сервера. Приложение должно проверить права текущего пользователя до записи и обработать исключение на уровне HTTP-ответа. Код выше — учебный пример порядка проверок, а не готовый пакет для любого типа файла.

\n

Сгенерированный ключ не содержит разделителей пути из имени клиента и не обязан совпадать с ним. Если ключ строится случайно, одинаковые имена вроде avatar.jpg не создают коллизии. Расширение берётся из принятого MIME-типа, а не копируется из $file['name'].

\n

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

\n

Симптомы и проверки

\n
СимптомПричинаПроверкаДействие
Сохраняется файл с любым расширениемПуть строится из nameОтправить ../../other.jpg и имя с пробеламиИгнорировать имя; создать ключ приложением
Текст проходит как изображениеПроверяется только type или суффиксПослать текстовый файл с именем photo.jpgОпределить тип временного файла и применить белый список
Большой файл падает позжеЛимит проверяет только формаОтправить файл больше договорного размераОтклонить до чтения изображения и проверить лимиты PHP
Файл доступен по угадываемому URLХранилище лежит в веб-корнеОткрыть URL каталога без UI приложенияПеренести объект за веб-корень; выдавать его через контроллер
Профиль ссылается на отсутствующий объектСсылка записана раньше переноса или без проверки результатаСымитировать ошибку прав на каталогСохранять ссылку только после успешного переноса и проверять возврат
\n

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

\n

Форма не заменяет сервер

\n
<form method='post' enctype='multipart/form-data' action='/profile/avatar.php'>\n  <input type='file' name='avatar' accept='image/jpeg,image/png' required>\n  <button type='submit'>Сохранить аватар</button>\n</form>
\n

enctype='multipart/form-data' нужен для передачи файла. accept ограничивает подсказку выбора в браузере. Оба атрибута не являются границей доверия. Запрос можно собрать вручную через curl или другой HTTP-клиент. Сервер должен одинаково обработать такой запрос и обычную отправку формы.

\n

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

\n
  1. Определить контракт маршрута: одно поле, список типов, максимальный размер, диапазон размеров изображения и правило доступа.
  2. Создать закрытое хранилище вне веб-корня и проверить права процесса PHP на запись без разрешения на выполнение загруженных файлов.
  3. Проверить обязательные поля и код UPLOAD_ERR_OK. При ошибке завершить запрос до работы с временным путём.
  4. Сравнить размер с лимитом приложения и сверить его с upload_max_filesize и post_max_size.
  5. Определить MIME-тип по временному файлу через Fileinfo и сопоставить его с точным белым списком.
  6. Прочитать размеры изображения и отклонить файл вне диапазона. Не использовать getimagesize() как единственный security-валидатор.
  7. Сгенерировать ключ, перенести файл через move_uploaded_file() и проверить результат.
  8. Сохранить в профиле ключ, серверный MIME-тип и размеры только после успешного переноса.
  9. Проверить успешный JPEG, успешный PNG, текст с расширением .jpg, файл больше лимита и ошибку записи в хранилище.
\n

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

\n

Этот пример не сканирует вредоносное содержимое, не устраняет CSRF и не делает безопасную перекодировку изображения. Он не защищает от исчерпания диска, чрезмерного числа запросов или особенностей библиотек, которые будут читать файл дальше. Для документов, архивов и видео нужны отдельные правила: другой белый список, лимиты распаковки или декодирования и отдельный способ выдачи.

\n

Даже для JPEG и PNG MIME-проверка не даёт права бездумно отправлять файл в ImageMagick, браузер или другой сервис. Каждый следующий потребитель должен иметь собственный лимит и обработку ошибки. Если изображение публичное, выдавайте его через заранее определённый URL и правильный заголовок типа. Если доступ зависит от пользователя, сначала проверяйте право, а затем отдавайте объект из закрытого хранилища.

\n

При любой проверочной ошибке обработчик не переносит временный файл, не создаёт ссылку в профиле и не сообщает клиенту внутренний путь. Логируйте код отказа и идентификатор операции, но не исходное имя без необходимости. Пользователю достаточно сообщения «файл не принят» и понятного объяснения лимита.

\n

Критерий готовности

\n

Маршрут готов для этого учебного контракта, если пять проверок дают наблюдаемый результат: допустимые JPEG и PNG появляются в закрытом хранилище под сгенерированными ключами; текстовый файл с расширением .jpg отклоняется; слишком большой файл отклоняется до переноса; ошибка прав не создаёт ссылку в профиле; прямой URL каталога не раскрывает объект. Проверку нужно выполнить на целевой конфигурации PHP и веб-сервера. Критерий подтверждает маршрут, но не заявляет, что приложение в целом безопасно для любых файлов.

\n

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

\n" + "contentHtml": "

Загрузка аватара обычно начинается с одного поля формы и вызова move_uploaded_file. Ошибка становится заметна позже: каталог uploads оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к .jpg. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.

\n

Вопрос этой заметки один: как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности? Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения. Цена ошибки — файл в веб-корне, который можно открыть или выполнить не по назначению.

\n

Сначала договоримся о результате

\n

Форма передаёт один файл avatar. Мы принимаем не более 2 МБ, только image/jpeg и image/png, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например 7f4a...c2.png. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.

\n
\"Путь
Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.
\n
ПроверкаЧто она отвечаетЧто делаем при отказе
UPLOAD_ERR_OKPHP полностью принял часть запросаНе читаем временный путь, показываем понятную ошибку загрузки
Лимит 2 МБФайл укладывается в договор аватараНе переносим файл и не пытаемся уменьшать его вслепую
finfo_fileКакой MIME-тип определён по временному файлуОтклоняем тип, которого нет в белом списке
Размеры изображенияПодходит ли картинка для интерфейсаОтклоняем слишком маленькое или слишком большое изображение
Сгенерированный ключКуда именно будет записан файлНикогда не составляем путь из исходного имени
\n

Обработчик без скрытого шага

\n

Проверка $_FILES[\"avatar\"][\"error\"] должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле type из $_FILES здесь намеренно не используется: его прислал клиент.

\n
<?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'] > $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    $size = getimagesize($file['tmp_name']);\n    if ($size === false) {\n        throw new RuntimeException('Не удалось прочитать размеры изображения');\n    }\n\n    list($width, $height) = $size;\n    if ($width < 64 || $height < 64 || $width > 3000 || $height > 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' => $storageKey,\n        'mime' => $mime,\n        'width' => $width,\n        'height' => $height,\n    ];\n}
\n

Почему порядок проверок важнее набора функций

\n

У move_uploaded_file есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам. Вызов также может перезаписать существующий destination, поэтому случайный ключ не следует считать заменой атомарного создания объекта в хранилище с высокой конкуренцией.

\n

Вызов getimagesize нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.

\n

Минимальная форма и проверка руками

\n
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">\n  <input type="file" name="avatar" accept="image/jpeg,image/png" required>\n  <button type="submit">Сохранить аватар</button>\n</form>
\n

Атрибут accept помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением .jpg и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.

\n

Отрицательный сценарий можно повторить без вредного файла. Создайте обычный текст, назовите его как JPEG и вручную передайте клиентский MIME-тип:

\n
mkdir -p fixtures\nprintf 'not an image\\n' > fixtures/not-an-image.jpg\n\ncurl -i -X POST \\\n  -F 'avatar=@fixtures/not-an-image.jpg;type=image/jpeg' \\\n  https://localhost/profile/avatar.php
\n

Ожидаемый результат задаёт контракт endpoint: запрос отклонён, постоянный объект и ссылка в профиле не созданы. Конкретный HTTP-статус выбирает сервис, например 400 или 415. Значение Fileinfo может зависеть от системной magic-базы, поэтому на целевом стенде нужно зафиксировать фактический ответ, а не подставлять его заранее.

\n

Порядок запуска

\n
  1. Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.
  2. Подключить форму с multipart/form-data и передать $_FILES[\"avatar\"] в функцию.
  3. После успешного вызова сохранить только storageKey, MIME-тип и размеры рядом с пользователем.
  4. Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.
  5. Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.
\n

Граница этого примера

\n

Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.

\n

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

\n" }