diff --git a/editorial/agent-rewrites/352.json b/editorial/agent-rewrites/352.json index 3c7eb25..54d6d51 100644 --- a/editorial/agent-rewrites/352.json +++ b/editorial/agent-rewrites/352.json @@ -1,7 +1,7 @@ { "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-тестами и проверкой файловой системы, а не только просмотром кода.
Симптом: личный документ открывается по прямому URL из /uploads без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в /uploads, а ссылка стала чем-то вроде /uploads/ivan-passport.pdf. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.
Здесь разбираю один вопрос: как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня? Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.
\nПользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением .pdf. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.
| Слой | Что в нём храним | Чего в нём нет |
|---|---|---|
Таблица documents | id, owner_id, storage_key, статус | Публичного URL и пути, собранного из имени пользователя |
| Закрытый каталог | Файл по ключу, созданному приложением | Оригинального имени и логики авторизации |
Маршрут /documents/{id}/download | Проверку текущего пользователя и HTTP-ответ | Свободного параметра path из запроса |
| Браузер | Содержимое файла после успешного ответа | Сведений о расположении файла на сервере |
Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в Content-Disposition тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.
<?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 $document = $query->fetch(PDO::FETCH_ASSOC);\n\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}\nSQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым 404; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.
На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом ready и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.
/documents/42/download: получает 200, заголовок Content-Type: application/pdf и байты тестового файла.404, а тело файла не попадает в ответ./uploads/<storage_key> не должен находить файл, потому что каталог не лежит в веб-корне.404 и запись в серверном журнале без абсолютного пути в ответе пользователю.Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.
\nУ readfile простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.
Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что header() вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного echo раньше заголовков.