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-шляхами та перевіряти їх.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ