Что попадает в $_FILES

Загрузка файла в PHP начинается не с fopen(), а с HTTP-запроса POST с enctype="multipart/form-data". Веб-SAPI разбирает тело запроса, складывает обычные поля в $_POST, а файлы — в $_FILES. Это такая же граница внешнего ввода, как $_GET, $_POST и $_COOKIE из SAPI и суперглобалы: данным нельзя верить только потому, что они пришли в удобном массиве.

Минимальная HTML-форма выглядит так:

<form method="post" enctype="multipart/form-data" action="/upload-avatar.php">
    <input type="file" name="avatar" accept="image/png,image/jpeg">
    <button type="submit">Загрузить</button>
</form>

После отправки PHP создаёт примерно такую структуру:

$_FILES['avatar'] = [
    'name' => 'me.jpg',              // имя от клиента
    'full_path' => 'me.jpg',         // PHP 8.1+: путь/имя, переданные браузером
    'type' => 'image/jpeg',          // MIME от клиента, недоверенный
    'tmp_name' => '/tmp/phpA1B2.tmp', // временный файл на сервере
    'error' => UPLOAD_ERR_OK,
    'size' => 48231,
];

Главное поле здесь — не name и не type, а error. Если файл не дошёл, превысил лимит или PHP не смог записать временный файл, нормальной загрузки нет. tmp_name существует только временно: если файл не перенести в постоянное место в рамках обработки запроса, PHP удалит его после завершения.

flowchart TD A[Браузер отправляет POST multipart/form-data] --> B[Веб-SAPI разбирает тело запроса] B --> C[PHP создает временный файл] B --> D[Заполняет массив $_FILES] D --> E{error == UPLOAD_ERR_OK?} E -- нет --> F[Вернуть понятную ошибку] E -- да --> G[Проверить размер, MIME, allowlist] G --> H[Сгенерировать имя] H --> I[move_uploaded_file во storage вне web root] I --> J[Сохранить metadata в БД] J --> K[Отдавать через handler с проверкой доступа]
flowchart TD
    A[Браузер отправляет POST multipart/form-data] --> B[Веб-SAPI разбирает тело запроса]
    B --> C[PHP создает временный файл]
    B --> D[Заполняет массив $_FILES]
    D --> E{error == UPLOAD_ERR_OK?}
    E -- нет --> F[Вернуть понятную ошибку]
    E -- да --> G[Проверить размер, MIME, allowlist]
    G --> H[Сгенерировать имя]
    H --> I[move_uploaded_file во storage вне web root]
    I --> J[Сохранить metadata в БД]
    J --> K[Отдавать через handler с проверкой доступа]
Поток обработки upload: от multipart-запроса до безопасного хранения вне публичной директории.

Проверка upload errors

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

<?php

declare(strict_types=1);

function assertUploadedFile(string $field): array
{
    if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
        throw new RuntimeException('Файл не передан.');
    }

    $file = $_FILES[$field];

    if (!isset($file['error']) || is_array($file['error'])) {
        throw new RuntimeException('Некорректная структура upload-поля.');
    }

    return match ($file['error']) {
        UPLOAD_ERR_OK => $file,
        UPLOAD_ERR_NO_FILE => throw new RuntimeException('Файл не выбран.'),
        UPLOAD_ERR_INI_SIZE,
        UPLOAD_ERR_FORM_SIZE => throw new RuntimeException('Файл слишком большой.'),
        UPLOAD_ERR_PARTIAL => throw new RuntimeException('Файл был загружен только частично.'),
        UPLOAD_ERR_NO_TMP_DIR => throw new RuntimeException('На сервере нет временной директории.'),
        UPLOAD_ERR_CANT_WRITE => throw new RuntimeException('Сервер не смог записать файл.'),
        UPLOAD_ERR_EXTENSION => throw new RuntimeException('PHP-расширение остановило загрузку.'),
        default => throw new RuntimeException('Неизвестная ошибка загрузки.'),
    };
}

UPLOAD_ERR_FORM_SIZE связан с HTML-полем MAX_FILE_SIZE, но это не security-механизм: клиент может изменить HTML перед отправкой. Реальный лимит должен жить на сервере — в php.ini, настройках веб-сервера и в вашем коде.

Лимиты: post_max_size, upload_max_filesize, upload_tmp_dir

Для загрузок важны несколько директив:

file_uploads = 1
upload_tmp_dir = /var/tmp/php-upload
upload_max_filesize = 5M
post_max_size = 6M
max_file_uploads = 20
max_input_time = 60

upload_max_filesize ограничивает размер одного файла. post_max_size ограничивает весь HTTP body: файл, обычные поля формы и multipart-служебные границы. Поэтому post_max_size должен быть больше upload_max_filesize. Если весь запрос больше post_max_size, PHP может оставить $_POST и $_FILES пустыми, и ваш код даже не дойдёт до UPLOAD_ERR_INI_SIZE. Для UX обычно отдельно проверяют $_SERVER['CONTENT_LENGTH'], но это всё равно только симптом: лимиты должны быть согласованы на уровне PHP, Nginx/Apache, reverse proxy и приложения.

upload_tmp_dir должен быть доступен на запись пользователю, под которым работает PHP-FPM или другой SAPI. Если включён open_basedir, временная директория тоже должна попадать в разрешённые пути. Это уже пересекается с темой Файловая система и stream wrappers: upload — обычная работа с файлами, только источник у неё недоверенный.

MIME, расширения и имя файла

$_FILES['avatar']['type'] приходит от клиента. Его можно использовать для диагностики, но нельзя — для решения «разрешить или запретить». Имя файла тоже клиентское: оно может содержать пробелы, Unicode, двойные расширения вроде photo.jpg.php, слишком длинную строку или имя, совпадающее с уже существующим файлом.

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

<?php

declare(strict_types=1);

$file = assertUploadedFile('avatar');

$maxBytes = 2 * 1024 * 1024;
if (!isset($file['size']) || $file['size'] <= 0 || $file['size'] > $maxBytes) {
    throw new RuntimeException('Размер файла недопустим.');
}

$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
];

if (!is_string($mime) || !isset($allowed[$mime])) {
    throw new RuntimeException('Разрешены только JPEG и PNG.');
}

$storageDir = '/srv/app/storage/uploads/avatars'; // вне public/web root
$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = $storageDir . '/' . $filename;

if (!move_uploaded_file($file['tmp_name'], $target)) {
    throw new RuntimeException('Не удалось сохранить файл.');
}

chmod($target, 0640);

move_uploaded_file() важен именно для upload-потока: функция проверяет, что исходный файл действительно был загружен через HTTP POST upload mechanism, и только потом переносит его. Обычный rename() такой проверки не делает.

Для изображений часто добавляют ещё один слой: открыть файл через image-библиотеку и пересохранить в новый JPEG/PNG. Это помогает убрать лишние метаданные и часть странных вложений, но не отменяет лимиты, allowlist и хранение вне web root.

Где хранить загруженное

Самая опасная ошибка — положить пользовательский файл прямо в публичную директорию, где сервер исполняет PHP. Если атакующий загрузит shell.php, а Nginx/Apache отдаст его как PHP-скрипт, upload превращается в remote code execution. Даже если вы «проверяете расширение», остаются ошибки конфигурации, двойные расширения, MIME confusion и будущие регрессии.

Надёжный дефолт: хранить файлы вне web root, например в /srv/app/storage/uploads, а наружу отдавать их через контроллер или отдельный download handler. В базе храните не путь от пользователя, а внутренний идентификатор, оригинальное имя для отображения, нормализованный MIME, размер, владельца и timestamp.

<?php

// GET /files/7f3a...
// 1. Проверить, что текущий пользователь имеет доступ к файлу.
// 2. Найти запись в БД по внутреннему ID.
// 3. Отдать файл с безопасными заголовками.

header('Content-Type: image/png');
header('Content-Disposition: inline; filename="avatar.png"');
header('X-Content-Type-Options: nosniff');
readfile('/srv/app/storage/uploads/avatars/7f3a....png');

Если файл публичный, это всё равно не означает «пусть веб-сервер отдаёт всё из upload-директории». Handler даёт контроль доступа, rate limits, аудит, корректный Content-Type и возможность удалить или заменить файл без угадываемых URL. Для state-changing upload-форм нужны те же CSRF-защиты, что и для других изменяющих действий; см. CSRF и state-changing запросы.

Риски, которые часто недооценивают

Файл может быть вредным не только потому, что он «исполняемый». PDF, DOCX, SVG, архивы и изображения могут атаковать парсер, занимать слишком много памяти, содержать активный контент или быть zip bomb. Поэтому для сложных форматов нужны дополнительные проверки: antivirus/sandbox там, где это оправдано, Content Disarm & Reconstruct для офисных документов, запрет SVG для аватарок, ограничение количества файлов и квоты на пользователя.

Публичная выдача файла может создать XSS: например, если HTML или SVG отдать с исполняемым Content-Type. Поэтому для пользовательских файлов особенно важны корректные заголовки из HTTP-заголовки, ответы и редиректы, X-Content-Type-Options: nosniff и экранирование имени файла при выводе в HTML. Контекстное экранирование относится к XSS, экранирование вывода и шаблоны.

Практическая модель такая: upload handler не «принимает файл», а проводит его через pipeline — проверить ошибку, размер, тип, имя, права пользователя, место хранения, права на файловой системе и способ выдачи.

См. также

Источники

  1. PHP Manual: Handling file uploads
  2. PHP Manual: Error Messages Explained
  3. PHP Manual: move_uploaded_file
  4. PHP Manual: finfo_file
  5. PHP Manual: Description of core php.ini directives
  6. OWASP Cheat Sheet Series: File Upload Cheat Sheet