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.

Тому правило дня таке: для зовнішнього введення краще відхилити, ніж «очистити й прийняти». 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["Імʼя надійшло ззовні"] --> B{"fs.ValidPath(name)?"}
    B -- ні --> C["Повернути помилку: некоректний шлях"]
    B -- так --> D{"Чи починається name з 'templates/'?"}
    D -- ні --> E["Повернути помилку: заборонена область"]
    D -- так --> F["fs.ReadFile(fsys, name)"]
    F --> G{"err?"}
    G -- так --> H["Повернути помилку й обгорнути її"]
    G -- ні --> I["Повернути вміст"]

Ця схема корисна тим, що показує: безпека — це не «одна магічна функція», а послідовність простих перевірок на вході.

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 лекція
Недоступна
Шаблони листів
Шаблони листів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ