Глава 31 · Практика
Загрузка файлов: проверка содержимого, хранение и выдача
Аватар, PDF и импорт: почему имя, расширение и MIME клиента не являются доказательством безопасности.
Выбрать фотографию и понять, где она окажется
Посетитель прикладывает фотографию сломанной дрели к заявке. Нужно получить реальные байты изображения, проверить ограничения и сохранить файл для сотрудника. Поле с локальным путём C:\Pictures\drill.jpg само по себе не даёт PHP доступа к диску посетителя: браузер должен передать файл.
Сначала разберите обычную форму. У файловой формы три обязательных части: method="post", enctype="multipart/form-data" и input type="file" с name. enctype задаёт формат тела с отдельными частями для полей и файла. Например name="photo" создаёт ключ $_FILES["photo"], а не $_POST["photo"].
- Выбор drill.png в input name="photo"
- POST multipart: токен + имя файла + байты
- PHP кладёт байты во временный каталог
- $_FILES['photo'] содержит error, tmp_name, size, name, type
- Наш код проверяет и переносит в закрытый storage
- Человек видит подтверждение получения
| Ключ $_FILES["photo"] | Значение и предел доверия |
|---|---|
| error | Целый код состояния приёма; проверяется до чтения tmp_name. 0 означает UPLOAD_ERR_OK. |
| tmp_name | Созданный PHP временный путь; это вход для is_uploaded_file и move_uploaded_file. Не берите исходный путь из отдельного POST-поля. |
| name | Имя, присланное клиентом. Подходит только для осторожно показанной справки; не определяет путь хранения. |
| type | Заявленный клиентом MIME; не доказательство содержания. |
| size | Число байтов, сообщённое механизмом приёма; для политики ниже дополнительно читаем размер файла. |
| full_path | Может передаваться при загрузке каталогов в поддерживаемых версиях; клиентский путь недоверен. |
Без переноса временный upload обычно исчезает после запроса. move_uploaded_file проверяет, что исходник получен PHP через HTTP POST upload, и переносит его; rename такой проверки происхождения не делает. Ни одна из этих функций не доказывает, что файл — безопасная фотография. Это различие отвечает на реальный вопрос о move_uploaded_file.
Сначала разобрать результат приёма, затем читать файл
| Код | Что сказать человеку или оператору |
|---|---|
| UPLOAD_ERR_OK = 0 | Приём успешен; переходите к проверкам приложения. |
| UPLOAD_ERR_INI_SIZE = 1 | Превышен upload_max_filesize; предложите уменьшить файл. |
| 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; выяснить серверную причину. |
После error проверяем ожидаемую форму массива: посетитель может отправить photo[] и получить вложенные значения вместо одного файла. Неотмеченный файловый выбор и полное отсутствие ключа — разные ситуации. На уровне прокси задают ограничение размера запроса; post_max_size должен вмещать файл и служебные части multipart. Превышение общего POST-лимита может обнулить $_POST и $_FILES ещё до нашего обработчика.
Следующий обработчик принимает только один PNG/JPEG до 100 КиБ. finfo_file определяет MIME по данным файла с помощью расширения Fileinfo; расширение для хранения выбираем из своей карты. Это первичная сортировка для закрытого хранилища проверки. Картинка пока не выдаётся посетителям и не декодируется графической библиотекой. Для публичных изображений нужны отдельный проверенный этап обработки, пределы размеров/декодирования и безопасная выдача.
Передать настоящий PNG и сохранить его вне публичного каталога
Распакуйте ZIP в новую папку. public/index.php подключает example.php, storage существует рядом с public. Запуск: php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:8080 -t public. Откройте /index.php, выберите небольшой PNG и нажмите «Передать фотографию». Нужны сессии, Fileinfo, file_uploads=On и доступный PHP временный каталог.
Токен сессии здесь работает так же, как в форме заявки. Новые действия: is_uploaded_file подтверждает происхождение tmp_name; filesize читает фактический размер; finfo_open создаёт анализатор, finfo_file возвращает MIME. Массив extensions разрешает только два формата. random_bytes и bin2hex дают своё имя; move_uploaded_file возвращает true либо false. Ветвь успеха появляется только после true.
<?php
session_start(['use_strict_mode' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
$message = 'Выберите PNG или JPEG размером до 100 КиБ.';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf'] ?? null;
$file = $_FILES['photo'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
http_response_code(403);
$message = 'Обновите форму и попробуйте снова.';
} elseif (!is_array($file) || !is_int($file['error'] ?? null)) {
http_response_code(422);
$message = 'Отправьте один файл через поле фотографии.';
} elseif ($file['error'] === UPLOAD_ERR_NO_FILE) {
http_response_code(422);
$message = 'Сначала выберите фотографию.';
} elseif ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(422);
$message = 'Файл не получен полностью или превышает лимит сервера.';
} elseif (!is_string($file['tmp_name'] ?? null) || !is_uploaded_file($file['tmp_name'])) {
http_response_code(422);
$message = 'Не удалось подтвердить получение файла.';
} else {
$path = $file['tmp_name'];
$size = filesize($path);
$mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path);
$extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
if ($size === false || $size < 1 || $size > 102400 || !isset($extensions[$mime])) {
http_response_code(422);
$message = 'Нужен PNG или JPEG размером от 1 байта до 100 КиБ.';
} else {
$id = bin2hex(random_bytes(16));
$destination = __DIR__ . '/storage/' . $id . '.' . $extensions[$mime];
if (!move_uploaded_file($path, $destination)) {
http_response_code(503);
$message = 'Сейчас не удалось сохранить фотографию. Попробуйте позже.';
} else {
$message = 'Фотография получена и сохранена для проверки.';
}
}
}
} elseif ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
header('Allow: GET, POST');
exit('Допустимы GET и POST.');
}
?>
<!doctype html><html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Фотография для заявки</title><h1>Фотография для заявки</h1>
<p role="status"><?= htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<form action="/index.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
<p><label for="photo">PNG или JPEG, до 100 КиБ</label>
<input id="photo" name="photo" type="file" accept="image/png,image/jpeg" required style="max-width:100%"></p>
<button type="submit">Передать фотографию</button></form></html>
Ожидается 200 и «Фотография получена и сохранена для проверки.». В storage появляется случайно названный .png или .jpg с теми же байтами. URL /storage/имя.png недоступен при -t public. Имя на диске намеренно непредсказуемо, поэтому точное содержимое проверяется хешем в HTTP-тесте. Повторная отправка в этом небольшом примере создаёт ещё один файл; политика дедупликации здесь не реализована.
| Проверка | Результат |
|---|---|
| Не выбирать файл, обойдя required | 422, «Сначала выберите фотографию.» |
| Текстовый файл с именем image.png и MIME image/png от клиента | 422; серверное определение не принимает текст за PNG. |
| Файл размером более 102400 байтов, но в пределах серверного POST-лимита | 422; прикладной лимит не обходится поддельным именем. |
| Изменить csrf | 403 без сохранения. |
| Подать photo[] | 422 вместо чтения массива как пути. |
| Передать обычный локальный файл в move_uploaded_file в CLI | Переноса нет: это не HTTP upload. HTTP fixture обязателен для доказательства рабочего приёма. |
Для публичной системы добавляют привязку вложения к заявке/владельцу, права доступа, квоты и срок хранения, ограничение частоты и при необходимости проверку содержимого. CSRF сам не аутентифицирует отправителя. Ни accept в HTML, ни расширение имени, ни MIME от клиента не заменяют серверные правила. Далее сохранённые справочные разделы развивают множественную загрузку, ограничения сервера и безопасную выдачу.
Файл проходит собственный конвейер
Файл клиента сначала проходит проверку
Аватар должен стать разрешённым изображением в хранилище, а не исполняемым файлом в каталоге сайта.
- Браузер прислал файл
$_FILES содержит статус и временный путь. Имя photo.jpg и MIME клиента ещё ничего не доказывают.
- Проверить приём и содержимое
Проверяем ошибку загрузки, размер, допустимый MIME и нужные свойства изображения.
- Переместить разрешённый файл
move_uploaded_file(): случайное серверное имя, каталог вне public, связь с владельцем в базе.
- Выдать по разрешённому запросу
Снова проверяем доступ; задаём тип ответа. Путь на диске не берём напрямую из запроса.
При отказе файл не становится аватаром. Расширение и MIME — части проверки; одного имени файла недостаточно.
В магазине менеджер загружает фотографию товара, клиент — документ на возврат, интеграция — CSV с остатками. Эти файлы требуют разных правил. Для фотографии разумно разрешить несколько растровых форматов и выполнить контролируемое декодирование с ограничением размеров. Документ можно хранить как скачиваемое вложение, не исполняя и не открывая его на основном домене. SVG и HTML содержат активный контент; разрешать их как обычный аватар опасно.
Сначала нужны аутентификация, полномочие на объект, CSRF и лимит частоты. Размер ограничивают в прокси/веб-сервере, post_max_size, upload_max_filesize и прикладной политике. Если превысить общий POST-лимит, $_POST и $_FILES могут быть пустыми; это не означает, что пользователь просто забыл выбрать файл. Согласуйте лимиты и понятный ответ 413. Атрибут accept помогает диалогу выбора, но не серверу.
$_FILES содержит error, tmp_name, size, name и type. Форму массива тоже проверяют: неожиданные вложенные значения не должны попадать в файловые функции. Затем проверяют UPLOAD_ERR_OK и фактический размер временного файла. Клиентские name и type не используют для принятия решения. Определение MIME через Fileinfo — один слой, а не доказательство безвредности содержимого.
Получаем только ожидаемую структуру
После работающего приёма картинки выделим уже знакомую проверку error в отдельную функцию. Значение null в её ответе означает «ошибки приёма нет»; строки choose-file/incomplete — внутренние коды, которые интерфейс переведёт человеку. match выбирает код по константе UPLOAD_ERR_*, default описывает оставшиеся отказы. Это небольшая проверка одного слоя, она не заменяет multipart-проект выше.
<?php
declare(strict_types=1);
function uploadProblem(array $file): ?string {
if (!isset($file['error']) || !is_int($file['error'])) return 'bad-shape';
return match ($file['error']) {
UPLOAD_ERR_OK => null,
UPLOAD_ERR_NO_FILE => 'choose-file',
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'too-large',
UPLOAD_ERR_PARTIAL => 'incomplete',
default => 'storage-unavailable',
};
}
foreach ([['error'=>UPLOAD_ERR_OK],['error'=>UPLOAD_ERR_PARTIAL],['error'=>[]]] as $file) {
echo uploadProblem($file) ?? 'continue';
echo "
";
}
Ожидаемый результат
continue
incomplete
bad-shape
Это только первый этап. После continue ещё предстоят проверка временного файла, размера, MIME, содержимого, назначения и прав. Не повторяйте частично полученный файл как успешный.
Случайное имя и каталог вне public
Новое требование — принимать PDF и вернуть его внутреннее имя вызывающему коду. storePdf получает метаданные одного файла и закрытый каталог, а возвращает строку имени. throw сообщает вызывающему коду об отказе вместо ложного имени. Запись (new finfo(FILEINFO_MIME_TYPE))->file(...) — объектный вариант знакомых finfo_open/finfo_file: создаём анализатор и вызываем его метод file. DIRECTORY_SEPARATOR задаёт разделитель пути платформы; 5_000_000 — целое 5000000 с разделителями для чтения. Ниже контекстная функция, которую HTTP-контроллер вызывает после метода, CSRF и проверки доступа.
<?php
declare(strict_types=1);
function storePdf(array $file, string $privateDirectory): string {
if (($file['error'] ?? null) !== UPLOAD_ERR_OK
|| !is_string($file['tmp_name'] ?? null)
|| !is_uploaded_file($file['tmp_name'])) {
throw new InvalidArgumentException('Invalid upload');
}
$size = filesize($file['tmp_name']);
if ($size === false || $size < 1 || $size > 5_000_000) {
throw new InvalidArgumentException('File size rejected');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if ($mime !== 'application/pdf') throw new InvalidArgumentException('Expected PDF');
$name = bin2hex(random_bytes(24)).'.pdf';
$path = $privateDirectory.DIRECTORY_SEPARATOR.$name;
if (file_exists($path) || !move_uploaded_file($file['tmp_name'], $path)) {
throw new RuntimeException('Cannot store upload');
}
return $name;
}
Нужны HTTP multipart upload, Fileinfo и заранее созданный закрытый каталог вне webroot с безопасными правами. Вызывать только после доступа и CSRF. PDF здесь помещается в карантин: MIME не заменяет антивирус/проверку формата, а выдача ещё не разрешена. Случайное имя имеет 192 бита; каталог не должен быть доступен для записи посторонним.
Не подставляйте исходное имя в путь. Последовательности ../, разделители, одинаковые имена и различия регистров файловых систем создают ошибки и уязвимости. Исходное имя можно хранить как метаданные, а физический ключ генерировать случайно. move_uploaded_file дополнительно проверяет происхождение источника как HTTP-upload; обычный rename этого не делает. При этом функция может перезаписать существующее назначение, поэтому проектируйте уникальность и приватность хранилища.
База хранит владельца, тип, размер, состояние проверки и физический ключ. Если файл сохранён, а вставка метаданных не удалась, его нужно удалить либо подобрать фоновым уборщиком сирот. Обратная последовательность требует состояния pending. Файловая система и SQL не составляют общую транзакцию, поэтому приходится явно проектировать восстановление после сбоя.
Выдача — отдельное авторизованное действие
Закрытый файл отдаёт контроллер, который проверяет пользователя и принадлежность вложения, выбирает путь только из доверенных метаданных и задаёт Content-Type, Content-Disposition и X-Content-Type-Options: nosniff. Для документов предпочтительна загрузка attachment. Имя в Content-Disposition требует безопасного формирования: нельзя вставлять сырые кавычки и переводы строк. Большие файлы лучше передавать веб-серверу через контролируемый внутренний механизм после авторизации, чтобы не занимать PHP-воркер долгой отдачей.
Для картинок ограничьте не только байты, но и ширину, высоту, число пикселей и ресурсы декодера. Маленький сжатый файл может требовать много памяти после распаковки. Перекодирование снимает часть риска и метаданные, но библиотека обработки тоже нуждается в обновлениях и лимитах. Проверки ZIP требуют защиты от выхода путей за каталог, симлинков и чрезмерной распаковки. Автоматически распаковывать любой архив из формы нельзя.
Тестируйте пустой файл, обрыв загрузки, запрещённое расширение с разрешённым MIME, подменённый MIME, огромные размеры изображения и скачивание чужого документа. Время антивирусной проверки и перекодирования измеряют отдельно; если оно велико, интерфейс показывает состояние «проверяется», а работа уходит в очередь.
Карантин, квоты и восстановление после сбоя
Хранилище должно учитывать общую квоту пользователя, а не только размер одного файла. Сто разрешённых файлов по пять мегабайт тоже способны заполнить диск. Проверка квоты до записи подвержена гонке двух загрузок; резервирование места или атомарный учёт в базе решают её надёжнее, чем доверие одному предварительному SELECT. Учитывайте и временные файлы во время приёма.
Состояния pending, scanning, ready и rejected делают процесс понятным человеку. Пока проверка не завершилась, файл не выдаётся как готовый. Если сканер недоступен, выбирают явное отложенное состояние, а не автоматически принимают документ. Отказ объясняют без раскрытия внутренних путей; исходное имя можно показать только после HTML-экранирования.
Фоновая уборка должна удалять только объекты, отмеченные вашей моделью как сироты или истёкшие временные загрузки. Нельзя рекурсивно очищать путь, вычисленный из клиентского имени. Для каждого удаления сверяйте корень хранилища и ожидаемый ключ. Полезный отказной тест останавливает процесс после сохранения файла, но до записи метаданных, затем проверяет, что механизм восстановления не удаляет чужие успешные вложения.
Практика: проверьте решение, а не только успешный путь
Нужно принимать аватары JPEG/PNG. Почему проверки размера и finfo недостаточно?
Разбор решения
Нужны ограничения пикселей и ресурсов, безопасное декодирование/перекодирование, случайное имя, закрытое или отдельное хранилище, запрет исполнения и корректные заголовки. Проверка доступа/CSRF не исчезает. Изображение с неверным содержимым или чрезмерными размерами должно отклоняться до дорогой обработки.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
is_uploaded_fileПроверить, что PHP зарегистрировал данный временный путь как файл текущей HTTP POST-загрузки.
is_uploaded_file(string $filename): boolПроверить, что PHP зарегистрировал данный временный путь как файл текущей HTTP POST-загрузки.
$name — переменная; = сохраняет результат, кавычки задают строку, ; завершает команду. name(...) вызывает функцию с аргументами через запятую. echo выводит; "\n" переносит строку, var_dump показывает также тип. __DIR__ — папка текущего PHP-файла; точка . соединяет строки. if выбирает ветку; === false отличает отказ от 0 байтов и пустой строки, а !== false допускает строку "0". [...] создаёт массив; $row["size"] читает поле по ключу; [$a, $b] = ... раскладывает список. Переменные; Вызов и return; Поток и позиция чтения.
| Параметр или результат | Договор |
|---|---|
| filename | Значение $_FILES[поле][tmp_name], а не клиентское name и не самостоятельно присланная строка пути. |
| Возврат | true для зарегистрированной загрузки, false иначе. |
| Отказ | Обычный существующий CLI-файл даёт false. true ничего не утверждает о безопасном содержимом, MIME или праве пользователя. |
| Версия | PHP4.0.3+ |
Синтаксис формы: $_FILES — массив информации, $_POST — текстовые поля, ?? null даёт запасное значение. session_start открывает состояние сессии; hash_equals сравнивает токен. http_response_code задаёт HTTP-статус, exit завершает запрос. Форма и CSRF; Полная последовательность безопасной загрузки.
- Обычный fixture data.txt
- Файл существует
- is_uploaded_file → false
- Он не прошёл механизм HTTP upload
<?php
$path = __DIR__ . '/data.txt';
var_dump(is_file($path), is_uploaded_file($path));
Ожидаемый результат
bool(true)
bool(false)
PHP ведёт внутренний список загруженных файлов; имя нельзя сделать upload простым присваиванием.
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])
|| !hash_equals($_SESSION['csrf'], $token)) { http_response_code(403); exit; }
$upload = $_FILES['document'] ?? null;
if (!is_array($upload) || ($upload['error'] ?? null) !== UPLOAD_ERR_OK
|| !is_string($upload['tmp_name'] ?? null) || !is_int($upload['size'] ?? null)
|| $upload['size'] < 1 || $upload['size'] > 1024) { http_response_code(400); exit; }
if (!is_uploaded_file($upload['tmp_name'])) { http_response_code(400); exit; }
header('Content-Type: text/plain; charset=UTF-8');
echo "origin accepted\n";
HTTP-контекст: форма multipart/form-data, имя document, CSRF из той же сессии; route должен предварительно проверить аутентификацию/право загрузки. Код проверяет только этап происхождения, не принимает содержимое в постоянное хранилище. При валидном запросе ожидается origin accepted.
Применение и границы. Перед чтением/показом временного файла важно проверить происхождение, затем формат, размер и право доступа. Не выводите клиентское name без экранирования. move_uploaded_file уже включает проверку происхождения при переносе; повторная is_uploaded_file перед ним не заменяет остальные проверки.
Сравните: move_uploaded_file, is_file. Полный выбор файловых операций.
Продолжение: сохранённая прежняя практика.
Возвращает true, если PHP знает файл как загруженный через HTTP POST. Обычный локальный файл не проходит проверку.
<?php
declare(strict_types=1);
echo is_uploaded_file(__FILE__) ? "upload\n" : "not HTTP upload\n";
Ожидаемый результат
not HTTP upload
Сам PHP-скрипт существует, но не является загруженным браузером файлом.
<?php
declare(strict_types=1);
function requireUpload(array $file): string {
$path=$file['tmp_name'] ?? null;
if (!is_string($path) || !is_uploaded_file($path)) throw new InvalidArgumentException('upload');
return $path;
}
HTTP-функция; вызывающий код сначала проверяет error и затем продолжает лимитами/типом/содержимым.
Рядом по задаче. move_uploaded_file включает проверку происхождения при перемещении; Fileinfo проверяет тип отдельно.
move_uploaded_fileПеренести только файл настоящей HTTP POST-загрузки в выбранное приложением хранилище.
move_uploaded_file(string $from, string $to): boolПеренести только файл настоящей HTTP POST-загрузки в выбранное приложением хранилище.
$name — переменная; = сохраняет результат, кавычки задают строку, ; завершает команду. name(...) вызывает функцию с аргументами через запятую. echo выводит; "\n" переносит строку, var_dump показывает также тип. __DIR__ — папка текущего PHP-файла; точка . соединяет строки. if выбирает ветку; === false отличает отказ от 0 байтов и пустой строки, а !== false допускает строку "0". try выполняет попытку, catch обрабатывает указанный отказ, finally освобождает ресурс и не подавляет ошибку. throw new RuntimeException сообщает о сбое. [...] создаёт массив; $row["size"] читает поле по ключу; [$a, $b] = ... раскладывает список. Переменные; Вызов и return; Поток и позиция чтения.
| Параметр или результат | Договор |
|---|---|
| from | Временный tmp_name, зарегистрированный PHP. |
| to | Путь назначения приложения. Существующий файл будет перезаписан; клиентское имя не используйте как путь. |
| Возврат | true при переносе; false без действий для не-upload; false с warning если настоящий upload не удаётся перенести. |
| Отказ | Ограничение open_basedir проверяется для to, позволяя from находиться в системной upload-temp. Поддержка переноса не даёт разрешения публиковать содержимое. |
| Версия | PHP4.0.3+ |
В HTTP-примере new finfo(...) создаёт анализатор MIME, ->file(...) вызывает его метод; это чтение содержимого на сервере. $moved отмечает успешность переноса для finally. Перед этим применён такой же POST/CSRF/структурный контроль, как в is_uploaded_file. Форма, права и жизненный цикл загрузки.
- Обычный data.txt
- move_uploaded_file отвергает источник
- false
- Исходник остаётся
<?php
$from = __DIR__ . '/data.txt';
var_dump(move_uploaded_file($from, __DIR__ . '/destination.txt'));
var_dump(file_exists($from), file_exists(__DIR__ . '/destination.txt'));
Ожидаемый результат
bool(false)
bool(true)
bool(false)
Для обычного файла существует rename; отказ здесь — часть защиты происхождения.
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])
|| !hash_equals($_SESSION['csrf'], $token)) { http_response_code(403); exit; }
$upload = $_FILES['document'] ?? null;
if (!is_array($upload) || ($upload['error'] ?? null) !== UPLOAD_ERR_OK
|| !is_string($upload['tmp_name'] ?? null) || !is_int($upload['size'] ?? null)
|| $upload['size'] < 1 || $upload['size'] > 1024) { http_response_code(400); exit; }
if (!is_uploaded_file($upload['tmp_name'])) { http_response_code(400); exit; }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
if ($mime !== 'text/plain') { http_response_code(415); exit; }
$storage = realpath(__DIR__ . '/../storage');
if ($storage === false || !is_dir($storage)) { throw new RuntimeException('storage'); }
$target = tempnam($storage, 'doc');
if ($target === false) { throw new RuntimeException('reserve'); }
$moved = false;
try {
if (realpath(dirname($target)) !== $storage) { throw new RuntimeException('wrong directory'); }
if (!move_uploaded_file($upload['tmp_name'], $target)) { throw new RuntimeException('move'); }
$moved = true;
header('Content-Type: text/plain; charset=UTF-8');
echo "stored\n";
} finally { if (!$moved) { unlink($target); } }
HTTP-контекст: endpoint лежит в public/, существующая storage/ рядом с public и не обслуживается веб-сервером. Есть fileinfo, сессия/CSRF, проверка права пользователя до route. tempnam резервирует собственное имя: оно не удаляется перед переносом. Успех stored. Текстовые файлы всё равно экранируются при будущем HTML-показе; это не универсальный антивирус.
Применение и границы. $_FILES[type] заявляет клиент, поэтому тип проверяется сервером; одной MIME-проверки недостаточно для всех форматов. Дальше нужны квоты, политика хранения/очистки, связь с владельцем документа и контролируемая выдача. Полный обработчик должен иметь единый отказ без раскрытия системного пути.
Сравните: is_uploaded_file, tempnam, rename. Полный выбор файловых операций.
Продолжение: сохранённая прежняя практика.
Проверяет происхождение источника, перемещает файл и возвращает bool. При проблеме назначения возможны предупреждение и false.
<?php
declare(strict_types=1);
$ok=move_uploaded_file(__FILE__,'must-not-exist.php');
echo $ok ? "unexpected\n" : "rejected source\n";
Ожидаемый результат
rejected source
Источник не является HTTP-upload, поэтому файл не перемещается и назначение не создаётся.
<?php
declare(strict_types=1);
function moveVerifiedUpload(string $temporary,string $privateDirectory): string {
$name=bin2hex(random_bytes(24)).'.bin';
$destination=$privateDirectory.DIRECTORY_SEPARATOR.$name;
if (file_exists($destination) || !move_uploaded_file($temporary,$destination)) throw new RuntimeException('store failed');
return $name;
}
HTTP-контекст после CSRF, доступа, лимитов и проверки формата. Каталог заранее создан вне webroot и недоступен для записи посторонним.
Рядом по задаче. rename перемещает обычные файлы без upload-проверки; полный конвейер загрузки.
finfo::fileОпределить тип файла по его содержимому, а не по суффиксу имени.
public function file(string $filename, int $flags = FILEINFO_NONE, ?resource $context = null): string|falseОпределить тип файла по его содержимому, а не по суффиксу имени.
| Часть вызова | Договор |
|---|---|
| filename | Путь к анализируемому файлу. Строка пути не является содержимым. Для загруженного файла берут проверенный $_FILES[…]["tmp_name"], не клиентское name. |
| flags | Необязательный ненулевой набор временно заменяет флаги объекта на один вызов, затем прежние восстанавливаются. FILEINFO_NONE = 0 здесь сохраняет текущие настройки; для постоянного сброса set_flags(FILEINFO_NONE). |
| context | Контекст потока, nullable с 8.0. Может влиять на открытие через поддерживаемую обёртку. Не позволяйте посетителю выбирать произвольный URL для проверки: это создаёт сетевой запрос сервера. |
| Возврат | Строка описания/типа/кодировки по флагам или false при ошибке. Не равняйте неизвестный application/octet-stream ошибке вызова. |
| Ошибки | Недоступный файл — предупреждение и false. Пустой путь — ValueError. Нулевой байт в пути с 8.5 даёт ValueError, раньше TypeError. Это не то же правило, что для нулевого байта в buffer. |
В коде $ начинает имя переменной, = сохраняет возвращённое значение, скобки передают аргументы вызова. new finfo(...) создаёт настроенный объект; ->file(...) или ->buffer(...) вызывает его метод. === false проверяет именно неуспех, не любую пустую строку. throw new RuntimeException(...) останавливает обычный путь выполнения с объектом ошибки; catch перехватывает указанный тип, а finally завершает обязательную очистку даже при ошибке. Разбор try, catch и finally.
- Файл document.jpg содержит Hello world
- libmagic проверяет содержимое по правилам
- MIME_TYPE возвращает text/plain
- Приложение отдельно решает, разрешён ли этот тип
<?php
declare(strict_types=1);
$detector = new finfo(FILEINFO_MIME_TYPE);
$type = $detector->file('document.jpg');
if ($type === false) { throw new RuntimeException('Cannot detect type'); }
echo $type, "\n";
Ожидаемый результат
text/plain
Файл назван .jpg, но его содержимое — текст. Имя не превратило его в изображение; MIME-тип остаётся эвристическим результатом, не полным разбором формата.
<?php
declare(strict_types=1);
$detector = new finfo(FILEINFO_MIME_TYPE);
echo $detector->file('document.jpg', FILEINFO_MIME_ENCODING), "\n";
echo $detector->file('document.jpg'), "\n";
echo $detector->file('document.jpg', FILEINFO_NONE), "\n";
Ожидаемый результат
us-ascii
text/plain
text/plain
Второй и третий результаты показывают, что временный режим не остался у объекта. Переданный 0 не сбрасывает его в описательный режим.
<?php
declare(strict_types=1);
$detector = new finfo(FILEINFO_MIME_TYPE);
try { $type = $detector->file("bad\0path"); }
catch (ValueError $error) { echo "Invalid path\n"; }
Ожидаемый результат
Invalid path
Пример рассчитан на PHP 8.5+. Нулевой байт отвергнут до открытия; раньше этот случай имел TypeError. Не убирайте такой байт молча, превращая исходное имя в другой путь.
У анализа пути тоже есть стоимость чтения и внутренней обработки. Ограничивайте допустимые размеры до тяжёлых операций; не используйте анализ каталогов/устройств как эквивалент приёма обычного файла. Полный путь приёма загрузки.
Сравните: finfo::buffer, finfo_set_flags, mime_content_type. Тип, кодировка и границы определения.
Объект finfo(FILEINFO_MIME_TYPE) возвращает MIME-строку либо false. Нужны расширение Fileinfo и доступный локальный файл.
<?php
declare(strict_types=1);
$type=(new finfo(FILEINFO_MIME_TYPE))->file(__FILE__);
echo is_string($type) ? "type detected\n" : "failed\n";
Ожидаемый результат
type detected
Точный MIME зависит от базы magic, поэтому тест проверяет успешный контракт без утверждения конкретной строки.
<?php
declare(strict_types=1);
function allowedAvatarMime(string $path): bool {
$mime=(new finfo(FILEINFO_MIME_TYPE))->file($path);
return in_array($mime,['image/jpeg','image/png'],true);
}
Функция лишь один этап: затем ограничивают размеры изображения, декодируют/перекодируют и хранят безопасно.
Рядом по задаче. finfo::buffer работает с байтами в памяти; is_uploaded_file проверяет происхождение.
Источники и обсуждения
- PHP: features.file-upload
- PHP: function.move-uploaded-file
- OWASP: загрузка файлов
- Обсуждение: move_uploaded_file и происхождение файла — обсуждение проблемы
- PHP: features.file-upload.post-method
- PHP: features.file-upload.errors
- PHP: features.file-upload.multiple
- PHP: function.is-uploaded-file
- PHP: function.move-uploaded-file
- PHP: function.finfo-file