Files
progcode/editorial/agent-rewrites/352.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
15 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&lt;?php\n\nfunction sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void\n{\n $query = $pdo-&gt;prepare(\n 'SELECT storage_key\n FROM documents\n WHERE id = :id AND owner_id = :owner_id AND status = :status'\n );\n $query-&gt;execute([\n ':id' =&gt; $documentId,\n ':owner_id' =&gt; $currentUserId,\n ':status' =&gt; 'ready',\n ]);\n\n $document = $query-&gt;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' =&gt; '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>"
}