Файлы: path traversal и загрузка

Имя файла от пользователя — это ввод, и обращаться с ним надо так же осторожно.

Path traversal — выход за пределы разрешённой папки через последовательности вроде ../ в пользовательском пути, дающий доступ к чужим файлам.

Как возникает path traversal

Приложение строит путь к файлу из пользовательского ввода, склеивая базовую папку и имя. Если во вводе есть ../, путь «всплывает» вверх по дереву к произвольным файлам системы.

Корень проблемы тот же, что и у инъекций: данные от пользователя смешиваются с управляющей конструкцией — здесь это не SQL-запрос, а путь в файловой системе. Разработчик мысленно представляет, что в переменную name придёт что-то вроде avatar.png, и строит путь под это ожидание. Но клиент присылает строку, которую захочет, и последовательность «вверх по дереву» — совершенно легальная часть синтаксиса путей. Файловая система честно выполнит навигацию, не подозревая, что часть пути пришла от недоверенной стороны.

Последствия выходят за рамки чтения. В зависимости от того, как используется путь, traversal позволяет не только прочитать конфиг с секретами или системный файл, но и записать файл в неожиданное место — например, перезаписать скрипт, который потом исполнится, или подложить файл в каталог автозагрузки. Поэтому к построению пути из ввода относятся так же серьёзно, как к построению SQL-запроса: это операция на границе доверия.

// Уязвимо: имя файла из запроса клеится к базовой папке
path = "/var/app/files/" + request.query.name;
// name = ../../../../etc/passwd  ->  выход за пределы папки

Защита: каноникализация и проверка границы

Приведите итоговый путь к абсолютной канонической форме (разворачивая .. и симлинки) и убедитесь, что он остаётся внутри разрешённой папки. Дополнительно — allowlist для имени файла.

// Безопасно: канонический путь обязан начинаться с базовой папки
base = realpath("/var/app/files");
target = realpath(join(base, userName));
if (!target.startsWith(base + "/")) reject();   // вышли за границу -> отказ

// Ещё безопаснее: не использовать имя из ввода в пути вовсе —
// хранить файлы под сгенерированными id, а имя держать в БД

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

Почему именно каноникализация, а не «вырезать ../ из строки»? Потому что наивная фильтрация почти всегда обходится. Существуют разные кодировки тех же символов, варианты разделителей пути в разных ОС, абсолютные пути, символические ссылки, которые сами уводят за пределы папки. Пытаться перечислить все «плохие» формы — это денилист, и он проигрывает. Каноникализация переворачивает задачу: вы не угадываете опасные варианты ввода, а приводите итоговый путь к единственной нормальной форме и затем задаёте один понятный вопрос — лежит ли он внутри разрешённой папки. Это позитивная проверка границы, и её гораздо труднее обойти.

Особое внимание — символическим ссылкам. Даже если итоговое имя выглядит безобидно и лежит «внутри» папки, оно может быть ссылкой, указывающей наружу. Поэтому канонизировать нужно с разворачиванием симлинков (как делает realpath), а не только текстово сворачивать ... Иначе проверка границы пройдёт, а реальное обращение всё равно уйдёт за пределы каталога.

Безопасная загрузка файлов

Приём файлов добавляет рисков: подмена типа, исполняемый контент, переполнение диска, опасное имя. Каждый из этих рисков защищается отдельной проверкой, и важно держать их вместе как единый барьер: пропуск одного пункта часто обесценивает остальные. Чеклист:

ПроверкаЗачем
тип по содержимому, не по расширениюрасширение лжёт; смотрите сигнатуру/MIME
allowlist разрешённых типовпринимаем только нужное (png, pdf), а не «всё кроме»
лимит размеразащита от переполнения диска и DoS
генерируемое имя на дискеотвязка от пользовательского имени и расширения
хранение вне webrootзалитый файл не должен исполняться как скрипт
// Уязвимо: доверяем расширению и сохраняем под именем пользователя в webroot
save("/var/www/uploads/" + file.originalName);   // .php -> исполнится сервером

// Безопасно: проверяем содержимое, генерируем имя, храним вне webroot
if (!allowedTypes.has(detectType(file.bytes))) reject();
if (file.size > MAX_SIZE) reject();
save("/srv/uploads/" + uuid() + extFor(detectedType));   // не в webroot, новое имя

Как работает под капотом: почему расширение лжёт

Расширение и даже присланный заголовок Content-Type задаёт клиент — их легко подделать. Тип определяют по содержимому: первые байты файла (magic bytes) несут сигнатуру формата (например, PNG начинается с фиксированной последовательности). Но и определённый тип не делает контент безопасным: картинку всё равно стоит перекодировать через библиотеку, а не доверять «как есть».

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

Отдельная причина не доверять расширению — поведение самого сервера. Веб-сервер часто решает, исполнять файл или просто отдать, именно по расширению. Файл с «исполняемым» расширением, сохранённый в каталог, который сервер обслуживает, может быть запущен при обращении к нему по ссылке. Вот почему загрузки хранят под сгенерированным именем и вне webroot: даже если внутрь попало что-то опасное, у сервера нет повода и возможности это исполнить.

Частые ошибки

  • Проверять тип по расширению/Content-Type. Их подделывает клиент; смотрите содержимое.
  • Хранить загрузки в webroot под именем пользователя. Риск исполнения и traversal.
  • Нет лимита размера. Один запрос забивает диск (DoS).
  • Денилист расширений. Обходится; используйте allowlist типов.
  • Доверять имени файла для отображения без экранирования. Имя — тоже ввод; при выводе его на страницу применяйте те же правила экранирования, что и к любому пользовательскому тексту.
  • Текстовая фильтрация ../ вместо каноникализации. Обходится кодировками и симлинками; проверяйте границу по каноническому пути.

Итоги

  • Path traversal лечится каноникализацией пути и проверкой, что он внутри базовой папки.
  • Лучше не пускать пользовательскую строку в путь: храните по сгенерированному id.
  • Загрузки: тип по содержимому + allowlist + лимит размера + новое имя + вне webroot.
  • Имя файла от пользователя — это ввод: не доверяйте ему ни в пути, ни при выводе на страницу.
Проверьте себя
1. Как возникает уязвимость path traversal?
AИз-за слабого пароля
BПользовательский ввод с последовательностями вроде ../ попадает в путь и выводит за пределы разрешённой папки
CИз-за отсутствия HTTPS
DИз-за переполнения буфера
2. Почему тип загружаемого файла нельзя определять по расширению или Content-Type?
AОни слишком длинные
BИ расширение, и Content-Type задаёт клиент и легко подделывает; тип надёжнее определять по содержимому (сигнатуре)
CРасширения не поддерживаются на сервере
DContent-Type шифруется
3. Почему загруженные файлы стоит хранить вне webroot под сгенерированным именем?
AЧтобы быстрее раздавать их
BЧтобы залитый файл не мог исполниться как скрипт и чтобы отвязать хранение от пользовательского имени/расширения
CЧтобы уменьшить их размер
DЭто требование HTTP