Что попадает в $_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 с проверкой доступа]Проверка 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 = 60upload_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 — проверить ошибку, размер, тип, имя, права пользователя, место хранения, права на файловой системе и способ выдачи.
См. также
- SAPI и суперглобалы — почему
$_FILESнужно изолировать на границе приложения. - GET, POST и фильтрация ввода — как нормализовать внешний ввод до доменных значений.
- Файловая система и stream wrappers — базовые операции с путями, файлами и потоками.
- HTTP-заголовки, ответы и редиректы — как безопасно отдавать сохранённые файлы.
- CSRF и state-changing запросы — почему upload-форма тоже изменяющий запрос.
- Конфигурация безопасности PHP — production-настройки, которые ограничивают последствия ошибок.