8 lines
12 KiB
JSON
8 lines
12 KiB
JSON
{
|
||
"index": 352,
|
||
"slug": "editorial-2018-03-field-safe-uploads",
|
||
"title": "PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой",
|
||
"excerpt": "Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.",
|
||
"contentHtml": "<p>Симптом: личный документ открывается по прямому URL из <code>/uploads</code> без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в <code>/uploads</code>, а ссылка стала чем-то вроде <code>/uploads/ivan-passport.pdf</code>. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.</p>\n<p>Здесь разбираю один вопрос: <strong>как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня?</strong> Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.</p>\n<h2>У файла должны быть две разные сущности</h2>\n<p>Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением <code>.pdf</code>. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.</p>\n<figure><img src=\"/assets/editorial/2018/php-private-download-flow.svg\" alt=\"Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.\" /><figcaption>Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.</figcaption></figure>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Что в нём храним</th><th scope=\"col\">Чего в нём нет</th></tr></thead><tbody><tr><td>Таблица <code>documents</code></td><td><code>id</code>, <code>owner_id</code>, <code>storage_key</code>, статус</td><td>Публичного URL и пути, собранного из имени пользователя</td></tr><tr><td>Закрытый каталог</td><td>Файл по ключу, созданному приложением</td><td>Оригинального имени и логики авторизации</td></tr><tr><td>Маршрут <code>/documents/{id}/download</code></td><td>Проверку текущего пользователя и HTTP-ответ</td><td>Свободного параметра <code>path</code> из запроса</td></tr><tr><td>Браузер</td><td>Содержимое файла после успешного ответа</td><td>Сведений о расположении файла на сервере</td></tr></tbody></table></div>\n<h2>Небольшой обработчик PDF</h2>\n<p>Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в <code>Content-Disposition</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 $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}</code></pre>\n<p>SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым <code>404</code>; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.</p>\n<h2>Как воспроизвести проверку</h2>\n<p>На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом <code>ready</code> и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.</p>\n<ol><li>Анна запрашивает <code>/documents/42/download</code>: получает <code>200</code>, заголовок <code>Content-Type: application/pdf</code> и байты тестового файла.</li><li>Борис запрашивает тот же URL: получает <code>404</code>, а тело файла не попадает в ответ.</li><li>Запрос к предполагаемому пути <code>/uploads/<storage_key></code> не должен находить файл, потому что каталог не лежит в веб-корне.</li><li>Удаляем файл на диске при сохранённой записи: получаем <code>404</code> и запись в серверном журнале без абсолютного пути в ответе пользователю.</li><li>Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.</li></ol>\n<h2>Что будет, если оставить прямую ссылку</h2>\n<p>Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.</p>\n<h2>Ограничения этого решения</h2>\n<p>У <code>readfile</code> простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.</p>\n<p>Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что <code>header()</code> вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного <code>echo</code> раньше заголовков.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP File Upload Cheat Sheet</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.php.net/manual/en/function.readfile.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: readfile</a></li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6266\" target=\"_blank\" rel=\"noopener\">RFC 6266: Content-Disposition в HTTP</a></li></ul>"
|
||
}
|