Сервис должен сохранять имя, полученное от клиента, только внутри каталога загрузок. Что делает проверку пути небезопасной?
from pathlib import Path
base = Path("/srv/uploads")
name = request.query["name"]
target = base / name
if not str(target).startswith(str(base)):
raise ValueError("invalid path")
target.write_bytes(request.body)
Проверка небезопасна, потому что сравнивает необработанные строки, а не канонические пути. Злоумышленник может использовать ../ для выхода из каталога или подобрать путь с таким же строковым префиксом, например /srv/uploads-other.
Надёжная защита должна канонизировать путь, проверять его принадлежность разрешённому каталогу структурно, а не через startswith, и учитывать символические ссылки и гонки между проверкой и открытием файла.
Проблема возникла из-за смешения пользовательских данных с компонентами пути файловой системы. Когда имя файла напрямую влияет на путь, последовательности перехода в родительский каталог позволяют обратиться к объектам за пределами запланированной директории.
Позднее стало ясно, что одной нормализации строки недостаточно: символические ссылки, неоднозначное представление путей и параллельная замена файла могут нарушить уже выполненную проверку. Поэтому безопасные границы строят вокруг канонического разрешения пути и контролируемого открытия ресурса.
В примере значение ../config.ini формирует путь /srv/uploads/../config.ini, который фактически указывает за пределы каталога загрузок. При этом строка может не начинаться с точного текста /srv/uploads после ожидаемой обработки, а сама проверка вообще не учитывает семантику файловой системы.
Другой случай — имя вроде -other/file, если базовый путь оканчивается на uploads: получившийся путь /srv/uploads-other/file имеет тот же строковый префикс, хотя не находится внутри /srv/uploads. Компрометация может привести к перезаписи конфигурации, размещению исполняемого файла или раскрытию данных.
Сначала нужно получить канонический базовый каталог и канонический целевой путь, затем проверить отношение каталог-потомок структурным способом:
resolve() устраняет . и .. и раскрывает существующие символические ссылки, а relative_to() проверяет компоненты пути, поэтому /srv/uploads-other не считается потомком /srv/uploads. Однако этот пример не устраняет полностью TOCTOU-гонку: после проверки злоумышленник может изменить символическую ссылку или элемент пути до операции записи.
Для строгой защиты нужно открывать файл относительно уже открытого доверенного каталога с запретом перехода через символические ссылки и выхода из этого каталога. Конкретный механизм зависит от операционной системы; на Linux для таких задач применяются безопасные режимы открытия, включая openat2 с ограничением RESOLVE_BENEATH, либо последовательное открытие компонентов с контролем O_NOFOLLOW.
Ещё надёжнее не принимать от клиента путь вообще. Сервис может создать случайный идентификатор объекта, хранить оригинальное имя только как метаданные и самостоятельно формировать физический путь. Это уменьшает поверхность атаки, но требует отдельного контроля содержимого, прав доступа и очистки неиспользуемых файлов.
Сервис загрузки документов принимал имя файла от клиента и записывал его в общий каталог. Вариант с проверкой расширения был отклонён: расширение не определяет фактический путь и не предотвращает переход в родительский каталог. Вариант с startswith также оказался недостаточным из-за .., символических ссылок и ложных строковых префиксов.
В качестве компромиссного решения сервис стал назначать файлам случайные идентификаторы, а исходное имя сохранять отдельно в базе данных. Файлы создавались внутри заранее подготовленного каталога с ограниченными правами, а сервер выдавал их только через контролируемый обработчик. Это усложнило прямой доступ к файлам и потребовало дополнительного хранения метаданных, зато устранило необходимость доверять клиентскому пути.
../?Нет. Обход можно выразить через разные формы разделителей, кодирование, смешанный регистр на нечувствительных к регистру файловых системах или символические ссылки. Кроме того, фильтрация отдельных строковых фрагментов плохо определяет границу каталога; нужно анализировать канонический путь и безопасно выполнять операцию открытия.
resolve() всё ещё может быть недостаточной?Между проверкой и записью состояние файловой системы может измениться. Например, доверенный компонент пути может быть заменён символической ссылкой после проверки. Это классическая TOCTOU-гонка, поэтому для критичных операций проверку и открытие следует связывать с дескриптором доверенного каталога и запретами на переход через ссылки.
Непредсказуемый идентификатор снижает риск угадывания физического пути, но не определяет, кому разрешено читать или удалять объект. Если обработчик скачивания не проверяет владельца, арендатора или роль пользователя, файл всё равно может быть раскрыт через другой доступный идентификатор. Генерация имени защищает границу файловой системы, а авторизация защищает границу доступа к данным; нужны обе меры.