Симптом: обработчик пропускает файл с type=image/jpeg, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: if ($file["type"] === "image/jpeg"). Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.
Главный вопрос статьи: какие признаки файла можно использовать для какой проверки? Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием validateUpload().
Где заканчиваются сведения клиента
RFC 7578 описывает multipart/form-data: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в $_FILES; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.
| Сигнал | Откуда он взялся | Правильное применение |
|---|---|---|
$file["name"] | Имя, переданное клиентом | Показать как подпись после экранирования; не строить из него путь |
| Расширение | Часть клиентского имени | Использовать как удобный фильтр интерфейса, но не как доказательство типа |
$file["type"] | Content-Type multipart-части | Сохранить в отладочном журнале, но не использовать для допуска |
$file["error"] | Результат, который сообщил PHP | Продолжать только при UPLOAD_ERR_OK |
finfo_file() | Анализ временного файла на сервере | Сравнить с точным белым списком допустимых MIME-типов |
getimagesize() | Попытка прочитать параметры изображения | Проверить размеры после Fileinfo, но не считать это проверкой безопасности |
Короткий опыт на локальной машине
Ниже не нужен вредоносный файл. Достаточно обычного текста и вручную заданного Content-Type. Поднимите встроенный сервер PHP в каталоге с inspect.php, отправьте файл через curl и посмотрите на два значения. Конкретный MIME-результат Fileinfo может зависеть от его базы, но он определяется по временному файлу, а не по параметру type=image/jpeg в команде.
<?php
// inspect.php
$file = $_FILES['avatar'] ?? [];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Файл не получен');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detected = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
header('Content-Type: text/plain; charset=utf-8');
echo 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;
echo 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;
printf '<html>это не фотография</html>' > /tmp/not-an-image.txt
php -S 127.0.0.1:8080
curl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \
http://127.0.0.1:8080/inspect.php
Такой опыт не доказывает, что Fileinfo распознает все форматы без ошибок. Он доказывает более скромную вещь: строка $file["type"] описывает заявление отправителя, а не результат серверной проверки. Этого уже достаточно, чтобы убрать её из условия допуска.
Функция, которая возвращает только полезный контракт
После опыта можно свести проверку к небольшому контракту. Функция ниже не переносит файл и не создаёт запись в базе. Она отвечает только на вопрос, можно ли передать временный файл следующему шагу, и возвращает значение, которое тот шаг действительно использует.
<?php
function inspectImageUpload(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Загрузка не завершилась');
}
if (!isset($file['tmp_name'], $file['size']) || (int)$file['size'] > 2097152) {
throw new RuntimeException('Размер файла недопустим');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Fileinfo недоступен');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$extensions = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($extensions[$mime])) {
throw new RuntimeException('Допустимы только JPEG и PNG');
}
return [
'temporaryPath' => $file['tmp_name'],
'mime' => $mime,
'extension' => $extensions[$mime],
'bytes' => (int)$file['size'],
];
}
Почему это не «одна проверка вместо всех»
Fileinfo отвечает на вопрос о типе, но не о праве пользователя загружать файл, не о свободном месте и не о том, можно ли безопасно разбирать этот формат дополнительной библиотекой. В нашем случае разрешены только две картинки, поэтому белый список короткий. Если продукту нужны PDF, архивы и таблицы, лучше не расширять тот же массив до десятка значений, а сделать отдельные маршруты с отдельными лимитами и правилами выдачи.
Расширение всё ещё может быть полезным для интерфейса: по нему браузер открывает фильтр выбора, а пользователь понимает, какой файл выбрал. Но серверный ключ и расширение результата лучше строить из решения приложения: Fileinfo вернул image/png — приложение выбирает .png. Так имя не способно незаметно поменять путь или ожидаемый обработчик.
Последовательность проверки
- Проверить код
UPLOAD_ERR_*и остановиться до чтения временного файла при любой ошибке. - Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.
- Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.
- Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.
- Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.
Ограничения
Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.