From 10a07d0d35cbd54ca346f9e5da3c173b3d7df31a Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:00:52 +0300 Subject: [PATCH] =?UTF-8?q?EDITORIAL-352:=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D1=83=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editorial/agent-rewrites/352.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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-типа при загрузке не исправляет эту проблему: она происходит раньше выдачи.

\n

Тезис статьи простой: загрузка выбирает допустимый файл и ключ хранения, а выдача заново проверяет владельца и только потом читает байты. Путь на диске не должен приходить из URL. Пример ниже учебный. Он показывает границу между PHP-маршрутом, базой и файловой системой, но не является готовым файловым сервисом.

\n

Две сущности вместо одного имени

\n

У документа есть имя для человека: счёт за март.pdf. У хранилища есть ключ для приложения: 9f2a...c81d.pdf. Эти значения не нужно смешивать. Исходное имя можно сохранить в базе как подпись. Оно не должно участвовать в имени файла, SQL-пути или параметре include.

\n

В базе достаточно связать запись документа с владельцем и ключом хранения. Например: id, owner_id, storage_key, mime_type, status. Каталог /var/app/private-uploads лежит вне веб-корня. Веб-сервер не может отдать его по обычному URL. Приложение читает файл после проверки записи.

\n
\"Запрос
Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся перед чтением с диска.
\n

Где проходит граница доверия

\n

Клиент сообщает имя файла и отправляет multipart-часть. PHP сообщает, завершилась ли загрузка. Fileinfo и другие серверные проверки изучают временный файл. Ни один из этих сигналов не отвечает на вопрос, имеет ли текущий пользователь право читать документ. Это отдельная проверка по данным сессии и записи в базе.

\n
СимптомПричинаПроверкаДействие
Файл открывается по /uploads/...Хранилище находится в веб-корнеЗапросить предполагаемый прямой URL без сессииПеренести приватные байты за пределы веб-корня
В URL виден путь или имя файлаМаршрут принимает параметр pathОтправить ../ и путь к соседнему каталогуПринимать только ID записи, а путь строить из проверенного ключа
Чужой пользователь получает 200SQL ищет документ только по idПовторить запрос из второй сессииИскать по паре id + owner_id
Запись есть, файла нетПеренос или удаление не согласованы с базойУдалить тестовый файл и повторить скачиваниеВернуть контролируемый 404 и записать событие в журнал
Файл заменяется повторной загрузкойИмя клиента используется как имя назначенияЗагрузить два файла с одним именемСоздавать ключ на сервере и не перезаписывать существующий объект
\n

Учебный маршрут выдачи

\n

Маршрут принимает числовой идентификатор документа и текущего пользователя. SQL сразу включает владельца и статус ready. Так код не получает чужую запись для последующей ручной проверки. После результата проверяется формат ключа и наличие файла. Заголовки отправляются до тела ответа.

\n
<?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

Что происходит при загрузке

\n

Приём файла должен закончиться до создания ссылки на скачивание. Сначала код проверяет код доставки PHP, размер и тип содержимого по временному файлу. Затем создаёт случайный ключ и переносит файл в закрытый каталог. move_uploaded_file проверяет, что исходный путь был получен через HTTP POST, но эта функция не проверяет права пользователя и не делает содержимое безопасным. Эти задачи остаются у приложения.

\n

Исходное имя годится для подписи в интерфейсе после экранирования. Оно не годится для пути. Если назначение уже существует, перенос может перезаписать файл. Поэтому ключ должен быть непредсказуемым и уникальным, а операция создания — проверять конфликт. Учебный пример ниже показывает только идею контракта, без подключения к конкретной 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 без этих решений.

\n

Отрицательный путь важнее удачного

\n

Безопасность маршрута видна не по одному успешному скачиванию. Владелец должен получить документ. Чужой пользователь, неизвестный ID, неверный ключ и отсутствующий файл должны остановиться до readfile. Для внешнего клиента полезно возвращать одинаковый 404 для отсутствующей и чужой записи. Так маршрут не раскрывает, существует ли чужой документ. Внутренний журнал может сохранить ID и причину, но не абсолютный путь и не содержимое файла.

\n

Порядок действий

\n
  1. Создать каталог для приватных файлов вне веб-корня и дать процессу PHP только необходимые права.
  2. Определить белый список форматов, лимиты размера и правила создания серверного ключа.
  3. При загрузке проверить код доставки, размер и содержимое временного файла до переноса.
  4. Сохранить в базе владельца, статус, ключ и проверенные метаданные; исходное имя хранить отдельно.
  5. Сделать маршрут скачивания по ID записи, а владельца и статус включить в один запрос.
  6. Проверить формат ключа и наличие файла перед отправкой заголовков и тела ответа.
  7. Прогнать успешный сценарий владельца и отрицательные сценарии чужого пользователя, прямого URL, неверного ID и отсутствующего файла.
\n

Ограничения

\n

Закрытый каталог не является антивирусом. Проверка MIME-типа не доказывает отсутствие вредоносного содержимого. Для изображений могут понадобиться ограничения размеров и безопасная обработка. Для архивов появляются правила распаковки. Для больших файлов понадобятся диапазоны, потоковая отдача, лимиты скорости и отдельное хранилище.

\n

Маршрут из примера не решает CSRF, rate limit, аудит всех обращений, резервное копирование и согласованное удаление записи с объектом. Он также не учитывает прокси, CDN и кеши. Если перед маршрутом появляется кеш, в нём нельзя смешать ответы разных пользователей. Авторизация должна действовать до выдачи кешируемого содержимого или кеш нужно отключить.

\n

Проверяемый критерий готовности

\n

Решение готово для этого узкого сценария, если владелец получает ожидаемый файл с 200, а тот же ID из другой сессии получает 404 без тела документа. Прямой URL к каталогу недоступен. Запрос с неверным ID, ключом или отсутствующим файлом не вызывает readfile. Повторная загрузка не заменяет другой объект. Эти условия проверяются отдельными HTTP-тестами и проверкой файловой системы, а не только просмотром кода.

\n

Проверяемые источники

" + "title": "PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой", + "excerpt": "Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.", + "contentHtml": "

Симптом: личный документ открывается по прямому URL из /uploads без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в /uploads, а ссылка стала чем-то вроде /uploads/ivan-passport.pdf. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.

\n

Здесь разбираю один вопрос: как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня? Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.

\n

У файла должны быть две разные сущности

\n

Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением .pdf. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.

\n
\"Схема
Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.
\n
СлойЧто в нём хранимЧего в нём нет
Таблица documentsid, owner_id, storage_key, статусПубличного URL и пути, собранного из имени пользователя
Закрытый каталогФайл по ключу, созданному приложениемОригинального имени и логики авторизации
Маршрут /documents/{id}/downloadПроверку текущего пользователя и HTTP-ответСвободного параметра path из запроса
БраузерСодержимое файла после успешного ответаСведений о расположении файла на сервере
\n

Небольшой обработчик PDF

\n

Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в Content-Disposition тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.

\n
<?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}
\n

SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым 404; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.

\n

Как воспроизвести проверку

\n

На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом ready и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.

\n
  1. Анна запрашивает /documents/42/download: получает 200, заголовок Content-Type: application/pdf и байты тестового файла.
  2. Борис запрашивает тот же URL: получает 404, а тело файла не попадает в ответ.
  3. Запрос к предполагаемому пути /uploads/<storage_key> не должен находить файл, потому что каталог не лежит в веб-корне.
  4. Удаляем файл на диске при сохранённой записи: получаем 404 и запись в серверном журнале без абсолютного пути в ответе пользователю.
  5. Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.
\n

Что будет, если оставить прямую ссылку

\n

Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.

\n

Ограничения этого решения

\n

У readfile простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.

\n

Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что header() вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного echo раньше заголовков.

\n

Проверяемые источники

\n" }