{ "index": 354, "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" }