8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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><?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}</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><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></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>"
|
||
}
|