Файлы: 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.
- Имя файла от пользователя — это ввод: не доверяйте ему ни в пути, ни при выводе на страницу.