8 lines
15 KiB
JSON
8 lines
15 KiB
JSON
{
|
||
"index": 352,
|
||
"slug": "editorial-2018-03-field-safe-uploads",
|
||
"title": "PHP: безопасная загрузка и выдача приватных файлов",
|
||
"excerpt": "Файл можно проверить при загрузке и всё равно раскрыть при выдаче. Разбираем закрытое хранилище, запись владельца и PHP-маршрут, который отдаёт документ только после проверки доступа.",
|
||
"contentHtml": "<p>Симптом: личный документ открывается по прямому адресу вроде <code>/uploads/ivan-passport.pdf</code>. Браузер получает файл без проверки текущего пользователя. Ссылка попала в журнал, историю браузера или письмо — и стала самостоятельным правом доступа. Цена ошибки — раскрытие паспорта, договора или счёта не тому человеку. Проверка расширения и MIME-типа при загрузке не исправляет эту проблему: она происходит раньше выдачи.</p>\n<p>Тезис статьи простой: загрузка выбирает допустимый файл и ключ хранения, а выдача заново проверяет владельца и только потом читает байты. Путь на диске не должен приходить из URL. Пример ниже учебный. Он показывает границу между PHP-маршрутом, базой и файловой системой, но не является готовым файловым сервисом.</p>\n<h2>Две сущности вместо одного имени</h2>\n<p>У документа есть имя для человека: <code>счёт за март.pdf</code>. У хранилища есть ключ для приложения: <code>9f2a...c81d.pdf</code>. Эти значения не нужно смешивать. Исходное имя можно сохранить в базе как подпись. Оно не должно участвовать в имени файла, SQL-пути или параметре <code>include</code>.</p>\n<p>В базе достаточно связать запись документа с владельцем и ключом хранения. Например: <code>id</code>, <code>owner_id</code>, <code>storage_key</code>, <code>mime_type</code>, <code>status</code>. Каталог <code>/var/app/private-uploads</code> лежит вне веб-корня. Веб-сервер не может отдать его по обычному URL. Приложение читает файл после проверки записи.</p>\n<figure><img src=\"/assets/editorial/2018/php-private-download-flow.svg\" alt=\"Запрос на скачивание проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ\" /><figcaption>Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся перед чтением с диска.</figcaption></figure>\n<h2>Где проходит граница доверия</h2>\n<p>Клиент сообщает имя файла и отправляет multipart-часть. PHP сообщает, завершилась ли загрузка. Fileinfo и другие серверные проверки изучают временный файл. Ни один из этих сигналов не отвечает на вопрос, имеет ли текущий пользователь право читать документ. Это отдельная проверка по данным сессии и записи в базе.</p>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Файл открывается по <code>/uploads/...</code></td><td>Хранилище находится в веб-корне</td><td>Запросить предполагаемый прямой URL без сессии</td><td>Перенести приватные байты за пределы веб-корня</td></tr><tr><td>В URL виден путь или имя файла</td><td>Маршрут принимает параметр <code>path</code></td><td>Отправить <code>../</code> и путь к соседнему каталогу</td><td>Принимать только ID записи, а путь строить из проверенного ключа</td></tr><tr><td>Чужой пользователь получает <code>200</code></td><td>SQL ищет документ только по <code>id</code></td><td>Повторить запрос из второй сессии</td><td>Искать по паре <code>id + owner_id</code></td></tr><tr><td>Запись есть, файла нет</td><td>Перенос или удаление не согласованы с базой</td><td>Удалить тестовый файл и повторить скачивание</td><td>Вернуть контролируемый <code>404</code> и записать событие в журнал</td></tr><tr><td>Файл заменяется повторной загрузкой</td><td>Имя клиента используется как имя назначения</td><td>Загрузить два файла с одним именем</td><td>Создавать ключ на сервере и не перезаписывать существующий объект</td></tr></tbody></table></div>\n<h2>Учебный маршрут выдачи</h2>\n<p>Маршрут принимает числовой идентификатор документа и текущего пользователя. SQL сразу включает владельца и статус <code>ready</code>. Так код не получает чужую запись для последующей ручной проверки. После результата проверяется формат ключа и наличие файла. Заголовки отправляются до тела ответа.</p>\n<pre><code><?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}</code></pre>\n<p>Регулярное выражение фиксирует контракт ключа. Оно не заменяет авторизацию. Оно не доказывает, что файл действительно PDF. В этом маршруте MIME-тип задан ожидаемым контрактом записи. Если приложение принимает разные форматы, оно должно хранить и проверять тип отдельным белым списком, а не угадывать его по расширению.</p>\n<h2>Что происходит при загрузке</h2>\n<p>Приём файла должен закончиться до создания ссылки на скачивание. Сначала код проверяет код доставки PHP, размер и тип содержимого по временному файлу. Затем создаёт случайный ключ и переносит файл в закрытый каталог. <code>move_uploaded_file</code> проверяет, что исходный путь был получен через HTTP POST, но эта функция не проверяет права пользователя и не делает содержимое безопасным. Эти задачи остаются у приложения.</p>\n<p>Исходное имя годится для подписи в интерфейсе после экранирования. Оно не годится для пути. Если назначение уже существует, перенос может перезаписать файл. Поэтому ключ должен быть непредсказуемым и уникальным, а операция создания — проверять конфликт. Учебный пример ниже показывает только идею контракта, без подключения к конкретной ORM или очереди.</p>\n<pre><code>$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}</code></pre>\n<p>Этот фрагмент ограничивает тип и формирует ключ. Он не заменяет проверку <code>UPLOAD_ERR_OK</code>, лимит размера, CSRF-защиту, антивирусную проверку или контроль прав каталога. Учебный пример нельзя копировать в production без этих решений.</p>\n<h2>Отрицательный путь важнее удачного</h2>\n<p>Безопасность маршрута видна не по одному успешному скачиванию. Владелец должен получить документ. Чужой пользователь, неизвестный ID, неверный ключ и отсутствующий файл должны остановиться до <code>readfile</code>. Для внешнего клиента полезно возвращать одинаковый <code>404</code> для отсутствующей и чужой записи. Так маршрут не раскрывает, существует ли чужой документ. Внутренний журнал может сохранить ID и причину, но не абсолютный путь и не содержимое файла.</p>\n<h2>Порядок действий</h2>\n<ol><li>Создать каталог для приватных файлов вне веб-корня и дать процессу PHP только необходимые права.</li><li>Определить белый список форматов, лимиты размера и правила создания серверного ключа.</li><li>При загрузке проверить код доставки, размер и содержимое временного файла до переноса.</li><li>Сохранить в базе владельца, статус, ключ и проверенные метаданные; исходное имя хранить отдельно.</li><li>Сделать маршрут скачивания по ID записи, а владельца и статус включить в один запрос.</li><li>Проверить формат ключа и наличие файла перед отправкой заголовков и тела ответа.</li><li>Прогнать успешный сценарий владельца и отрицательные сценарии чужого пользователя, прямого URL, неверного ID и отсутствующего файла.</li></ol>\n<h2>Ограничения</h2>\n<p>Закрытый каталог не является антивирусом. Проверка MIME-типа не доказывает отсутствие вредоносного содержимого. Для изображений могут понадобиться ограничения размеров и безопасная обработка. Для архивов появляются правила распаковки. Для больших файлов понадобятся диапазоны, потоковая отдача, лимиты скорости и отдельное хранилище.</p>\n<p>Маршрут из примера не решает CSRF, rate limit, аудит всех обращений, резервное копирование и согласованное удаление записи с объектом. Он также не учитывает прокси, CDN и кеши. Если перед маршрутом появляется кеш, в нём нельзя смешать ответы разных пользователей. Авторизация должна действовать до выдачи кешируемого содержимого или кеш нужно отключить.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Решение готово для этого узкого сценария, если владелец получает ожидаемый файл с <code>200</code>, а тот же ID из другой сессии получает <code>404</code> без тела документа. Прямой URL к каталогу недоступен. Запрос с неверным ID, ключом или отсутствующим файлом не вызывает <code>readfile</code>. Повторная загрузка не заменяет другой объект. Эти условия проверяются отдельными HTTP-тестами и проверкой файловой системы, а не только просмотром кода.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.php.net/manual/en/function.move-uploaded-file.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: move_uploaded_file</a></li><li><a href=\"https://www.php.net/manual/en/function.header.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: header</a></li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6266\" target=\"_blank\" rel=\"noopener\">RFC 6266: Content-Disposition</a></li></ul>"
|
||
}
|