{ "index": 352, "slug": "editorial-2018-03-field-safe-uploads", "title": "PHP: безопасная загрузка и выдача приватных файлов", "excerpt": "Файл можно проверить при загрузке и всё равно раскрыть при выдаче. Разбираем закрытое хранилище, запись владельца и PHP-маршрут, который отдаёт документ только после проверки доступа.", "contentHtml": "
Симптом: личный документ открывается по прямому адресу вроде /uploads/ivan-passport.pdf. Браузер получает файл без проверки текущего пользователя. Ссылка попала в журнал, историю браузера или письмо — и стала самостоятельным правом доступа. Цена ошибки — раскрытие паспорта, договора или счёта не тому человеку. Проверка расширения и MIME-типа при загрузке не исправляет эту проблему: она происходит раньше выдачи.
Тезис статьи простой: загрузка выбирает допустимый файл и ключ хранения, а выдача заново проверяет владельца и только потом читает байты. Путь на диске не должен приходить из URL. Пример ниже учебный. Он показывает границу между PHP-маршрутом, базой и файловой системой, но не является готовым файловым сервисом.
\nУ документа есть имя для человека: счёт за март.pdf. У хранилища есть ключ для приложения: 9f2a...c81d.pdf. Эти значения не нужно смешивать. Исходное имя можно сохранить в базе как подпись. Оно не должно участвовать в имени файла, SQL-пути или параметре include.
В базе достаточно связать запись документа с владельцем и ключом хранения. Например: id, owner_id, storage_key, mime_type, status. Каталог /var/app/private-uploads лежит вне веб-корня. Веб-сервер не может отдать его по обычному URL. Приложение читает файл после проверки записи.
Клиент сообщает имя файла и отправляет multipart-часть. PHP сообщает, завершилась ли загрузка. Fileinfo и другие серверные проверки изучают временный файл. Ни один из этих сигналов не отвечает на вопрос, имеет ли текущий пользователь право читать документ. Это отдельная проверка по данным сессии и записи в базе.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Файл открывается по /uploads/... | Хранилище находится в веб-корне | Запросить предполагаемый прямой URL без сессии | Перенести приватные байты за пределы веб-корня |
| В URL виден путь или имя файла | Маршрут принимает параметр path | Отправить ../ и путь к соседнему каталогу | Принимать только ID записи, а путь строить из проверенного ключа |
Чужой пользователь получает 200 | SQL ищет документ только по id | Повторить запрос из второй сессии | Искать по паре id + owner_id |
| Запись есть, файла нет | Перенос или удаление не согласованы с базой | Удалить тестовый файл и повторить скачивание | Вернуть контролируемый 404 и записать событие в журнал |
| Файл заменяется повторной загрузкой | Имя клиента используется как имя назначения | Загрузить два файла с одним именем | Создавать ключ на сервере и не перезаписывать существующий объект |
Маршрут принимает числовой идентификатор документа и текущего пользователя. SQL сразу включает владельца и статус ready. Так код не получает чужую запись для последующей ручной проверки. После результата проверяется формат ключа и наличие файла. Заголовки отправляются до тела ответа.
<?php\n\nfunction sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void\n{\n $query = $pdo->prepare(\n 'SELECT storage_key\n FROM documents\n WHERE id = :id AND owner_id = :owner_id AND status = :status'\n );\n $query->execute([\n ':id' => $documentId,\n ':owner_id' => $currentUserId,\n ':status' => 'ready',\n ]);\n\n $document = $query->fetch(PDO::FETCH_ASSOC);\n if (!$document) {\n http_response_code(404);\n exit;\n }\n\n $key = (string) $document['storage_key'];\n if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) {\n error_log('Некорректный ключ документа ' . $documentId);\n http_response_code(404);\n exit;\n }\n\n $path = '/var/app/private-uploads/' . $key;\n if (!is_file($path)) {\n error_log('Не найден файл для документа ' . $documentId);\n http_response_code(404);\n exit;\n }\n\n header('Content-Type: application/pdf');\n header('Content-Disposition: attachment; filename=\"document.pdf\"');\n header('Content-Length: ' . filesize($path));\n\n readfile($path);\n exit;\n}\nРегулярное выражение фиксирует контракт ключа. Оно не заменяет авторизацию. Оно не доказывает, что файл действительно PDF. В этом маршруте MIME-тип задан ожидаемым контрактом записи. Если приложение принимает разные форматы, оно должно хранить и проверять тип отдельным белым списком, а не угадывать его по расширению.
\nПриём файла должен закончиться до создания ссылки на скачивание. Сначала код проверяет код доставки PHP, размер и тип содержимого по временному файлу. Затем создаёт случайный ключ и переносит файл в закрытый каталог. move_uploaded_file проверяет, что исходный путь был получен через HTTP POST, но эта функция не проверяет права пользователя и не делает содержимое безопасным. Эти задачи остаются у приложения.
Исходное имя годится для подписи в интерфейсе после экранирования. Оно не годится для пути. Если назначение уже существует, перенос может перезаписать файл. Поэтому ключ должен быть непредсказуемым и уникальным, а операция создания — проверять конфликт. Учебный пример ниже показывает только идею контракта, без подключения к конкретной ORM или очереди.
\n$allowed = [\n 'application/pdf' => 'pdf',\n];\n\n$mime = finfo_file($finfo, $file['tmp_name']);\nif (!is_string($mime) || !isset($allowed[$mime])) {\n throw new RuntimeException('Недопустимый тип файла');\n}\n\n$key = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];\n$target = '/var/app/private-uploads/' . $key;\n\nif (!move_uploaded_file($file['tmp_name'], $target)) {\n throw new RuntimeException('Файл не сохранён');\n}\nЭтот фрагмент ограничивает тип и формирует ключ. Он не заменяет проверку UPLOAD_ERR_OK, лимит размера, CSRF-защиту, антивирусную проверку или контроль прав каталога. Учебный пример нельзя копировать в production без этих решений.
Безопасность маршрута видна не по одному успешному скачиванию. Владелец должен получить документ. Чужой пользователь, неизвестный ID, неверный ключ и отсутствующий файл должны остановиться до readfile. Для внешнего клиента полезно возвращать одинаковый 404 для отсутствующей и чужой записи. Так маршрут не раскрывает, существует ли чужой документ. Внутренний журнал может сохранить ID и причину, но не абсолютный путь и не содержимое файла.
Закрытый каталог не является антивирусом. Проверка MIME-типа не доказывает отсутствие вредоносного содержимого. Для изображений могут понадобиться ограничения размеров и безопасная обработка. Для архивов появляются правила распаковки. Для больших файлов понадобятся диапазоны, потоковая отдача, лимиты скорости и отдельное хранилище.
\nМаршрут из примера не решает CSRF, rate limit, аудит всех обращений, резервное копирование и согласованное удаление записи с объектом. Он также не учитывает прокси, CDN и кеши. Если перед маршрутом появляется кеш, в нём нельзя смешать ответы разных пользователей. Авторизация должна действовать до выдачи кешируемого содержимого или кеш нужно отключить.
\nРешение готово для этого узкого сценария, если владелец получает ожидаемый файл с 200, а тот же ID из другой сессии получает 404 без тела документа. Прямой URL к каталогу недоступен. Запрос с неверным ID, ключом или отсутствующим файлом не вызывает readfile. Повторная загрузка не заменяет другой объект. Эти условия проверяются отдельными HTTP-тестами и проверкой файловой системы, а не только просмотром кода.