Загрузка файлов в PHP: обработка $_FILES и защита формы

Загрузка файлов в PHP: обработка $_FILES и защита формы Полезное

Загрузка файла в PHP — это прием файла, отправленного браузером через форму с кодировкой multipart/form-data, и перенос его из временного каталога в постоянное хранилище. Браузер кладет данные в суперглобальный массив $_FILES, а скрипт проверяет их и вызывает move_uploaded_file.

Ниже — минимальный рабочий пример формы и обработчика, разбор массива $_FILES, таблица кодов ошибок и главное: как не превратить загрузчик в дыру, через которую заливают исполняемый .php-файл. Примеры проверены на PHP 8.5 (актуальная стабильная ветка на сентябрь 2026; тот же код работает и на PHP 8.1-8.4).

HTML-форма для загрузки

Файл попадет на сервер только при двух условиях: у формы метод post и атрибут enctype="multipart/form-data". Без enctype браузер отправит лишь имя файла как обычное текстовое поле, а $_FILES останется пустым.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="utf-8">
    <title>Загрузка файла</title>
</head>
<body>
    <form action="upload.php" method="post" enctype="multipart/form-data">
        <input type="hidden" name="MAX_FILE_SIZE" value="2097152">
        <label for="userfile">Выберите файл:</label>
        <input type="file" id="userfile" name="userfile">
        <button type="submit">Загрузить</button>
    </form>
</body>
</html>

Имя поля (name="userfile") становится ключом в массиве: данные окажутся в $_FILES['userfile']. Скрытое поле MAX_FILE_SIZE не «подсказка браузеру»: его значение обрабатывает сам PHP — если файл превысит указанный размер, загрузка прервется с кодом UPLOAD_ERR_FORM_SIZE (2). Но клиент может это поле изменить или вовсе удалить, поэтому серверным барьером оно не является. Настоящий лимит задают директивы upload_max_filesize, post_max_size и собственная проверка размера на сервере, к которой мы вернемся ниже.

Что лежит в массиве $_FILES

После отправки формы $_FILES['userfile'] — это ассоциативный массив. Посмотреть его структуру можно так:

<?php
print_r($_FILES);

Для одного загруженного JPEG вывод будет примерно таким:

Array
(
    [userfile] => Array
        (
            [name] => photo.jpg
            [full_path] => photo.jpg
            [type] => image/jpeg
            [tmp_name] => /tmp/phpAb12Cd
            [error] => 0
            [size] => 6887
        )
)

Ключевой момент: name и type присылает браузер, им доверять нельзя. type — это заявленный клиентом MIME, его легко подделать. Значение имеет только tmp_name (путь во временном каталоге, куда PHP уже сохранил файл), size (размер в байтах, измеренный сервером) и error (код результата).

Поле Кто задает Можно доверять Назначение
name браузер нет исходное имя файла у клиента
type браузер нет заявленный MIME-тип
tmp_name PHP да путь к временной копии на сервере
size PHP да размер в байтах
error PHP да код ошибки загрузки

Коды ошибок загрузки

Поле error — всегда первое, что нужно проверить. Каждому коду соответствует именованная константа PHP; сравнивать лучше с константой, а не с числом.

Константа Код Что означает
UPLOAD_ERR_OK 0 файл загружен без ошибок
UPLOAD_ERR_INI_SIZE 1 превышен upload_max_filesize из php.ini
UPLOAD_ERR_FORM_SIZE 2 превышен MAX_FILE_SIZE из формы
UPLOAD_ERR_PARTIAL 3 файл загружен частично
UPLOAD_ERR_NO_FILE 4 файл не был отправлен
UPLOAD_ERR_NO_TMP_DIR 6 нет временного каталога
UPLOAD_ERR_CANT_WRITE 7 не удалось записать файл на диск
UPLOAD_ERR_EXTENSION 8 загрузку прервало расширение PHP

Кода 5 в списке нет: он не используется. Значения 1 и 2 отличаются источником лимита: код 1 — серверный php.ini, код 2 — поле MAX_FILE_SIZE в форме.

Небезопасный прием и его последствие

Самая частая ошибка новичка — сохранить файл под тем именем, что прислал клиент, прямо в каталог сайта:

<?php
// НЕБЕЗОПАСНО: имя берётся от клиента, каталог доступен по HTTP
$name = $_FILES['userfile']['name'];
move_uploaded_file($_FILES['userfile']['tmp_name'], 'uploads/' . $name);
echo 'Загружено: ' . $name;

Что произойдет: злоумышленник отправит через форму файл shell.php с PHP-кодом внутри. Скрипт послушно положит его в uploads/shell.php — в каталог, который отдается веб-сервером. Дальше атакующий открывает https://site.ru/uploads/shell.php, и сервер исполняет чужой код: это полный захват сайта (RCE). Проверка типа по $_FILES['type'] тут не спасает — этот MIME присылает клиент, и подделывается он так же легко, как и имя.

Базовый защищенный обработчик

Базовый защищенный обработчик строится на нескольких независимых проверках. Каждая закрывает свой класс атак. Это именно базовый шаблон против самого частого риска — заливки исполняемого файла; полный production-контур защиты шире, и что в него входит, я перечислю ниже.

<?php
// 1. Проверяем код ошибки
if (!isset($_FILES['userfile']) || $_FILES['userfile']['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit('Файл не загружен или пришёл с ошибкой');
}

$file = $_FILES['userfile'];

// 2. Файл действительно пришёл через HTTP POST, а не подсунут по пути
if (!is_uploaded_file($file['tmp_name'])) {
    http_response_code(400);
    exit('Некорректная загрузка');
}

// 3. Серверный лимит размера (не доверяем форме)
$maxBytes = 2 * 1024 * 1024; // 2 МБ
if ($file['size'] > $maxBytes) {
    http_response_code(400);
    exit('Файл больше 2 МБ');
}

// 4. Реальный MIME по содержимому файла, а не по имени
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);

// 5. Белый список: разрешённый тип -> расширение, которое присвоим сами
$allowed = [
    'image/jpeg'      => 'jpg',
    'image/png'       => 'png',
    'application/pdf' => 'pdf',
];
if (!isset($allowed[$mime])) {
    http_response_code(400);
    exit('Недопустимый тип файла');
}

// 6. Генерируем новое имя, расширение берём из белого списка
$newName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];

// 7. Каталог хранения - вне корня сайта
$target = '/var/www/storage/uploads/' . $newName;

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

echo 'Файл сохранён как ' . $newName;

Разберем принципы:

  • is_uploaded_file подтверждает, что путь ведет к файлу, полученному именно через загрузку. move_uploaded_file делает ту же проверку внутри, поэтому связка исключает подмену tmp_name на произвольный путь.
  • MIME определяем расширением fileinfo по содержимому (finfo), а расширение и имя генерируем сами через random_bytes. Клиентские name и type не участвуют в имени файла — заливка shell.php теряет смысл, потому что файл все равно сохранится как случайный .jpg.
  • Хранилище вынесено за пределы webroot (/var/www/storage/, а не /var/www/html/). Файл физически недоступен по прямой ссылке; отдаем его при необходимости отдельным скриптом, который сам решает, кому и что показать.

Основной способ — именно хранение вне webroot: тогда вопрос «исполнит сервер файл или нет» просто не возникает. Если по инфраструктуре хранить вне корня сайта нельзя, запретить исполнение скриптов в каталоге загрузок нужно средствами конкретного веб-сервера, а не универсальной строкой. Частый совет php_flag engine off в .htaccess переносим плохо: он относится к конфигурациям с mod_php, при PHP-FPM может не примениться или вызвать ошибку конфигурации, а сам .htaccess может быть отключен директивой AllowOverride None. Правильный порядок такой: отключить обработчики скриптов в проверенной конфигурации своего сервера (Apache или Nginx), запретить выполнение в каталоге и обязательно протестировать — положить туда контрольный .php-файл и убедиться запросом, что сервер отдает его как текст, а не исполняет.

Важно понимать границу: проверка MIME через finfo отсекает грубые подмены, но для некоторых форматов ее обходят (например, «полиглот»-файл, валидный и как картинка, и как скрипт). Поэтому для изображений надежнее пересохранять их через библиотеку обработки (GD/Imagick) — при перекодировании чужой код внутри файла не выживает. Белый список расширений всегда безопаснее черного: список запрещенных легко обойти регистром или менее известным исполняемым расширением.

Что еще нужно в production

Код выше закрывает заливку веб-шелла, но загрузка файлов — это не только риск исполняемого скрипта. Прежде чем считать загрузчик готовым к бою, добавьте к нему меры, которых в учебном примере нет:

  • Аутентификация и авторизация — кто вообще имеет право загружать файл, и проверка прав на конкретное действие.
  • Защита от CSRF — токен в форме, чтобы загрузку нельзя было инициировать с чужого сайта.
  • Квоты и лимиты — максимальное число файлов, суммарный объем на пользователя, защита от переполнения диска.
  • Сканирование содержимого — антивирус или sandbox для потенциально опасных типов; помните про ZIP/XML-бомбы, вредоносные документы и уязвимости парсеров.
  • Права доступа и журналирование — минимальные права на каталог хранения, запись факта загрузки в лог.
  • Безопасная выдача файла — отдавать через скрипт с проверкой прав и корректными заголовками (Content-Type, Content-Disposition), а не прямой ссылкой.
  • Политика удаления — когда и как файлы удаляются, чтобы хранилище не росло бесконтрольно.

Ориентир по полному списку мер — OWASP File Upload Cheat Sheet.

Лимиты размера

Максимальный размер задается в php.ini и не может быть увеличен из самого скрипта:

  • upload_max_filesize — предельный размер одного файла.
  • post_max_size — предельный размер всего POST-запроса; должен быть строго больше upload_max_filesize, а не равен ему: POST несет не только файл, но и служебные части multipart и другие поля формы, поэтому файл у самой границы upload_max_filesize при равных лимитах может не пройти. Практический ориентир: upload_max_filesize = 2M, post_max_size = 3M или выше с запасом на остальные поля.

Поле MAX_FILE_SIZE в форме, как я разобрал выше, обрабатывает PHP, но клиент может его подделать, поэтому итоговым барьером всегда должна быть серверная проверка $file['size'], как в примере.

Выводы

  • Форма загрузки обязана иметь method="post" и enctype="multipart/form-data", иначе $_FILES будет пустым.
  • Из $_FILES доверять можно только tmp_name, size и error; поля name и type присылает клиент, их подделывают.
  • Начинайте обработку с проверки error === UPLOAD_ERR_OK и is_uploaded_file, затем переносите файл через move_uploaded_file.
  • Сохранение файла под клиентским именем в каталог сайта — прямой путь к заливке веб-шелла и захвату сервера.
  • Базовая защита: серверный лимит размера, определение реального MIME по содержимому, белый список типов, новое сгенерированное имя и хранение вне webroot (запрет исполнения внутри каталога — только средствами конкретного веб-сервера и с проверкой контрольным файлом).
  • Это базовый шаблон против веб-шелла; для production добавьте аутентификацию и CSRF-защиту, квоты, сканирование содержимого, безопасную выдачу файла и журналирование.

Где применяется / связь с практикой

Загрузка файлов — базовый кирпич почти любого веб-приложения на PHP: аватары в профиле, вложения в тикетах, документы в личном кабинете, импорт данных из CSV. Ошибка именно здесь чаще всего оборачивается взломом, поэтому обработку $_FILES и защиту загрузчика разбирают на серверной разработке как отдельную тему с практикой на реальных фреймворках.

Освойте тему на практике

Системно освоить серверный PHP, работу с файлами и безопасность веб-приложений можно на курсе PHP-разработчик. Посмотреть формат занятий и темы вживую помогут открытые уроки Otus — бесплатные вебинары с практикой и разбором задач.

FAQ

Можно ли загрузить несколько файлов одной формой?
Да. Задайте полю имя-массив (name="userfile[]") и атрибут multiple; тогда $_FILES['userfile'] придет как массивы по каждому ключу (name, tmp_name, error и т.д.), которые перебирают в цикле, применяя те же проверки к каждому файлу.

Почему файл не загружается, хотя ошибок в коде нет?
Чаще всего дело в серверных лимитах: файл больше upload_max_filesize (код ошибки 1) или весь запрос больше post_max_size. Тонкость: если превышен лимит всего POST-запроса, PHP оставляет и $_POST, и $_FILES пустыми — кода UPLOAD_ERR_* для чтения уже не будет, а обработчик выдаст общее «файл не загружен». Чтобы отличить этот случай, до обращения к $_FILES сравните заголовок CONTENT_LENGTH с разобранным значением post_max_size и обработайте превышение отдельным сообщением. В остальных случаях проверяйте $_FILES['userfile']['error'] и настройки php.ini.

Достаточно ли проверить расширение файла в имени?
Нет. Имя и его расширение задает клиент, их подделывают. Определяйте тип по содержимому через finfo и сверяйте с белым списком, а имя для сохранения генерируйте сами.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)