DarkRiDDeR9 мин

PHP. Почему расширение и Content-Type не отвечают на вопрос «что за файл?»

PHPБезопасность

Симптом: обработчик пропускает файл с type=image/jpeg, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: if ($file["type"] === "image/jpeg"). Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.

Главный вопрос статьи: какие признаки файла можно использовать для какой проверки? Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием validateUpload().

Где заканчиваются сведения клиента

RFC 7578 описывает multipart/form-data: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в $_FILES; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.

Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo изучает временный файл, а приложение применяет собственный белый список.
Клиентские метаданные полезны для интерфейса и диагностики. Решение о допуске принимает приложение после проверки временного файла.
СигналОткуда он взялсяПравильное применение
$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. Так имя не способно незаметно поменять путь или ожидаемый обработчик.

Последовательность проверки

  1. Проверить код UPLOAD_ERR_* и остановиться до чтения временного файла при любой ошибке.
  2. Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.
  3. Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.
  4. Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.
  5. Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.

Ограничения

Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.

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