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-путями и валидировать их.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ