JavaRush /Курсы /Go SELF /fs.ValidPath и защита...

fs.ValidPath и защита от path traversal

Go SELF
43 уровень , 3 лекция
Открыта

1. Почему «путь от пользователя» — это не просто строка

Сначала давайте честно признаем: строка с путём выглядит безобидно. Ну путь и путь. Но проблема в том, что путь — это инструкция, куда именно программе идти за данными. Если эта инструкция приходит извне (из аргументов, конфигов, HTTP, импорта, сценария теста), то вы фактически даёте внешнему миру возможность направить вашу программу в неожиданные места.

Это не какая-то редкая паранойя. Стандартная библиотека Go в целом эволюционирует в сторону принципа «safe by default» и отдельными API закрывает классы уязвимостей, связанных с доступом к файлам и обходом ограничений.

Представим типичную задачу из «нормальной жизни». В нашем учебном приложении (условно назовём его tasker) мы уже умеем работать с файлами: сохранять данные, читать конфиг, брать шаблоны из директории data/. И вот появляется функция:

«Покажи шаблон по имени name».

И пользователь (или внешний код) передаёт name. Казалось бы — что может пойти не так?

Что такое path traversal на человеческом языке

Чтобы понять проблему, полезно представить директорию как «двор с забором». os.DirFS("data") — это наш забор: мы говорим «разрешено читать только внутри data». Но злоумышленник (или просто любопытный коллега) может попробовать пролезть под забором с помощью специальной формы пути.

Path traversal — это попытка выйти за пределы разрешённого корня через сегменты вида .. (подняться на уровень вверх) или похожие трюки. Например, если ваша программа думает, что читает файл data/templates/welcome.txt, а ей подсунули "../secrets.txt", то она потенциально попытается открыть что-то вне data.

Простейшая «вредная» строка выглядит так:

  • "../secret.txt" — «поднимись на уровень выше, возьми secret.txt»
  • "a/../../secret.txt" — «сначала зайди в a, потом два раза выйди наверх»

И даже если вы не пишете интернет-сервис, а делаете обычную CLI-утилиту, проблема остаётся: путь может прийти из скрипта, из CI, из чужого конфига, из интеграции. В итоге «локальная» программа внезапно становится частью цепочки поставки (supply chain), и ей уже нельзя доверять «как себе в пятницу вечером».

2. Два мира путей: OS-путь и FS-путь

Сейчас будет маленькая, но критичная дисциплина: не путать пути ОС и пути внутри fs.FS.

Путь ОС — это то, что живёт в реальной файловой системе: там бывают C:\..., бывают обратные слэши \ на Windows, бывают абсолютные пути, бывают специфичные правила платформы.

FS-путь — это имя файла внутри абстракции fs.FS. И вот тут у Go очень практичная идея: FS-путь — это относительное имя, обычно со слэшами /, без «абсолютного смысла ОС». Эта договорённость позволяет os.DirFS и fstest.MapFS работать одинаково.

Сравнение удобно держать в табличке:

Что сравниваем Путь ОС Путь внутри fs.FS
Разделитель зависит от ОС (/ или \) всегда /
Может быть абсолютным да (/etc/hosts, C:\Windows\...) нет
Нормализация filepath.Clean чаще path.Clean, но для безопасности важнее fs.ValidPath
Контекст «файлы на диске» «файлы внутри конкретной FS»

Почему это важно для безопасности? Потому что как только вы начинаете «склеивать» OS-пути руками или пропускать в FS абсолютные пути, вы ломаете границы. А границы — это и есть безопасность.

4. fs.ValidPath: что проверяет и чего не делает

Теперь главный герой лекции: fs.ValidPath.

Важно воспринимать fs.ValidPath(name) как проверку формы, а не проверку «существует ли файл». Он отвечает на вопрос:

«Эта строка похожа на корректный FS-путь?»

А не на вопрос:

«Можно ли открыть файл и прочитать данные?»

Если совсем по-простому, fs.ValidPath отсекает «подозрительные» варианты, в которых чаще всего живёт traversal:

  • ведущий / (нам не нужны абсолютные FS-пути)
  • сегменты .. (выход «наверх»)
  • пустые сегменты (например, "a//b")
  • и в целом нарушения формата пути внутри FS

Мини-демо, которое полезно прям один раз прогнать глазами:

package main

import (
	"fmt"
	"io/fs"
)

func main() {
	fmt.Println(fs.ValidPath("cfg/app.txt"))   // true
	fmt.Println(fs.ValidPath("../secret"))    // false
	fmt.Println(fs.ValidPath("/etc/passwd"))  // false
	fmt.Println(fs.ValidPath("a//b.txt"))     // false
}

Обратите внимание на тонкий момент: fs.ValidPath("cfg/app.txt") == true не значит, что файл существует. Это значит лишь: «строка выглядит допустимо». Проверка существования — это уже Open, Stat, ReadFile.

5. Почему Clean — не защита

Очень хочется сделать «красиво»: взять ввод пользователя, «причесать» его Clean-ом, и жить спокойно. Так часто делают новички, потому что это выглядит логично: «ну я же нормализовал путь, значит всё ок».

Проблема в том, что нормализация может спрятать атаку, а не остановить её.

Посмотрим:

package main

import (
	"fmt"
	"io/fs"
	"path"
)

func main() {
	raw := "a/../b.txt"

	fmt.Println(path.Clean(raw))   // b.txt
	fmt.Println(fs.ValidPath(raw)) // false
}

Здесь path.Clean(raw) превращает путь в "b.txt". То есть «следы выхода наверх» исчезли, и если вы потом бездумно откроете "b.txt", вы уже не отличите «нормальный ввод» от «ввода с попыткой traversal».

Поэтому правило дня такое: для внешнего ввода лучше “reject”, чем “clean and accept”. fs.ValidPath как раз про это: если путь невалидный — возвращаем ошибку и не делаем никаких файловых операций.

6. Безопасное чтение шаблонов

Давайте сделаем практическую вещь: функцию чтения «шаблона» из FS. Мы предполагаем, что где-то в приложении есть каталог шаблонов (например, templates/ внутри нашей FS), и мы хотим читать файл по имени, которое пришло извне.

Сделаем пакет internal/templates (название не принципиально), и внутри — функцию ReadTemplate.

package templates

import (
	"fmt"
	"io/fs"
)

func ReadTemplate(fsys fs.FS, name string) (string, error) {
	if !fs.ValidPath(name) {
		return "", fmt.Errorf("invalid template path: %q", name)
	}

	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read template %q: %w", name, err)
	}
	return string(b), nil
}

Здесь сразу две важные привычки.

Во-первых, мы валидируем name до чтения файла: если это внешняя строка, она должна пройти «фейс-контроль».

Во-вторых, мы добавляем контекст к ошибке через fmt.Errorf(... %w ...), чтобы наверху можно было и сообщение человеку показать, и причину не потерять. Этот стиль в Go считается базовой инженерной практикой, потому что он не ломает распознавание причин через errors.Is/errors.As.

7. os.DirFS: «ограда» + проверка имени

На практике защита обычно делается в два слоя.

Первый слой — ограничиваем корень через os.DirFS("data") (или другую директорию). Это похоже на «ограду»: даже если кто-то попытается схитрить, FS сама по себе считается ограниченной корнем.

Второй слой — валидируем имя через fs.ValidPath. Это похоже на «проверку пропуска»: мы говорим «внутрь вообще нельзя с такими подозрительными документами».

Мини-пример использования (условно в main или в слое приложения):

package main

import (
	"fmt"
	"os"

	"example.com/tasker/internal/templates"
)

func main() {
	fsys := os.DirFS("data")

	text, err := templates.ReadTemplate(fsys, "templates/welcome.txt")
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Println(text)
}

Обратите внимание: ReadTemplate ничего не знает про диск, про текущую директорию, про абсолютные пути. Она работает с fs.FS, а значит её можно тестировать без диска.

8. Сценарии, тесты и политика доступа

Что даёт fs.ValidPath в реальной жизни

Сейчас полезно проговорить эффект «по пунктам», но сделаем это человеческим текстом, без превращения лекции в чек-лист.

Если пользователь случайно вводит "/templates/welcome.txt", то это не «страшная атака», но это неправильная форма FS-пути. fs.ValidPath сразу отсеет и даст вам возможность вернуть понятную ошибку: «ожидался относительный путь внутри FS».

Если пользователь вводит "../welcome.txt", то это уже классический traversal. Мы не пытаемся «починить» путь и угадать, что он имел в виду. Мы говорим: «нет». И это очень полезное «нет», потому что оно превращает потенциальную проблему безопасности в обычную ошибку валидации.

Если пользователь вводит "templates//welcome.txt", то это странная форма пути: два слэша подряд. fs.ValidPath отсеет и это. В результате у вас единый стандарт формата путей: меньше сюрпризов, меньше «оно работало на моей машине».

Мини-тесты через fstest.MapFS

Раз уж мы в прошлой лекции дня научились тестировать файловую логику без диска, давайте закрепим: безопасность путей — это как раз то, что удобно тестировать.

Сделаем тест: «валидный путь читается», «невалидный путь отвергается».

package templates

import (
	"testing"
	"testing/fstest"
)

func TestReadTemplate_OK(t *testing.T) {
	fsys := fstest.MapFS{
		"templates/welcome.txt": {Data: []byte("hi")},
	}

	got, err := ReadTemplate(fsys, "templates/welcome.txt")
	if err != nil {
		t.Fatalf("ReadTemplate: %v", err)
	}
	if got != "hi" {
		t.Fatalf("got %q", got)
	}
}

А теперь главное — тест на traversal. Мы не обязаны проверять текст ошибки до символа (это часто делает тесты хрупкими), но мы точно хотим убедиться, что функция не читает файл и возвращает ошибку.

package templates

import (
	"testing"
	"testing/fstest"
)

func TestReadTemplate_TraversalBlocked(t *testing.T) {
	fsys := fstest.MapFS{
		"secret.txt": {Data: []byte("nope")},
	}

	_, err := ReadTemplate(fsys, "../secret.txt")
	if err == nil {
		t.Fatalf("expected error")
	}
}

Этот тест не доказывает, что мы «защищены от всего на свете». Но он гарантирует базовую вещь: наша функция не принимает пути с ... А это уже огромный шаг от «наивного чтения файла по строке».

Политика доступа: ValidPath не заменяет правила приложения

Тут важно не впасть в магическое мышление: «раз ValidPath есть, значит безопасность готова». Это всего лишь проверка формата.

Например, допустимый FS-путь "templates/welcome.txt" может всё равно быть «не тем файлом», если вы в своей FS разместили лишнее. Поэтому у вас остаётся архитектурная обязанность: правильно выбирать корень DirFS, правильно раскладывать файлы, и по возможности делать отдельные директории под разные типы данных.

В реальном приложении часто полезно иметь понятный префикс и проверять его (например, разрешаем только templates/...). Это уже не задача fs.ValidPath, а задача вашего бизнес-правила: «какие именно файлы можно читать».

Если хочется аккуратно встроить это в код, можно сделать так:

package templates

import (
	"fmt"
	"io/fs"
	"strings"
)

func ReadTemplate(fsys fs.FS, name string) (string, error) {
	if !fs.ValidPath(name) {
		return "", fmt.Errorf("invalid template path: %q", name)
	}
	if !strings.HasPrefix(name, "templates/") {
		return "", fmt.Errorf("template must be under templates/: %q", name)
	}

	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read template %q: %w", name, err)
	}
	return string(b), nil
}

Да, это ещё один if. Зато это if, который экономит часы расследований и делает поведение программы предсказуемым.

9. Блок-схема безопасного чтения файла

Иногда полезно увидеть процесс как алгоритм, особенно если вы только привыкаете к Go-подходу «валидация → ранний возврат → действие».

flowchart TD
    A["name пришёл извне"] --> B{"fs.ValidPath(name)?"}
    B -- нет --> C["return error: invalid path"]
    B -- да --> D{"strings.HasPrefix(name, 'templates/')?"}
    D -- нет --> E["return error: forbidden area"]
    D -- да --> F["fs.ReadFile(fsys, name)"]
    F --> G{"err?"}
    G -- да --> H["return error (wrap)"]
    G -- нет --> I["return content"]

Эта схема полезна тем, что показывает: безопасность — это не «одна волшебная функция», а последовательность простых проверок на входе.

10. Типичные ошибки при работе с fs.ValidPath и защитой от traversal

Ошибка №1: валидировать путь после чтения файла.
Это звучит смешно, но встречается регулярно: сначала делают fs.ReadFile, потом, если что-то пошло не так, начинают проверять строку. Валидация должна стоять до I/O. Иначе вы уже попытались выполнить потенциально опасную операцию.

Ошибка №2: думать, что fs.ValidPath проверяет существование файла.
ValidPath вообще не про существование. Он не трогает FS и не делает Open. Он лишь отвечает: «строка допустима по формату». Существование — это Open/Stat/ReadFile, и там будут свои ошибки.

Ошибка №3: «почистить» путь через path.Clean и считать, что проблема решена.
Нормализация полезна для внутренних путей, которые вы сами генерируете. Но для внешнего ввода «почистить и принять» — плохая стратегия, потому что вы можете превратить подозрительный ввод в «обычный» и потерять возможность отказать.

Ошибка №4: собирать FS-путь через filepath.Join.
filepath.Join делает путь под правила ОС. На Windows он может вернуть строку с \, а FS-мир живёт на /. В результате fs.ValidPath может отвергнуть путь, а вы будете долго смотреть на строку и думать «ну это же join, он же умный». Для FS-путей обычно уместнее path.Join.

Ошибка №5: забывать, что os.DirFS задаёт корень, а не «приклеивает префикс».
Некоторые пытаются сделать так: dir := "data"; name := dir + "/" + userInput. Это возвращает вас в мир ручной склейки строк, где легко ошибиться, и где защита от traversal становится вашей проблемой в полном объёме. Гораздо спокойнее: создать fsys := os.DirFS("data"), а дальше работать относительными FS-путями и валидировать их.

1
Задача
Go SELF, 43 уровень, 3 лекция
Недоступна
Проверка пути
Проверка пути
1
Задача
Go SELF, 43 уровень, 3 лекция
Недоступна
Отчёт фильтра
Отчёт фильтра
1
Задача
Go SELF, 43 уровень, 3 лекция
Недоступна
Доступ к конфигу
Доступ к конфигу
1
Задача
Go SELF, 43 уровень, 3 лекция
Недоступна
Шаблоны писем
Шаблоны писем
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ