Дайджесты новостей
Архитектурный разрез защищённого файлового контейнера, блокирующего попытки Path Traversal на уровне ядра операционной системы в Go.

filepath.Join не защитит от обхода каталога: разбор os.OpenInRoot и уязвимости GO-2026-4970 в Go

Если спросить среднестатистического Go-разработчика, как безопасно прочитать файл, имя которого передал внешний пользователь в HTTP-запросе, ответ почти наверняка будет стереотипным: «Возьми базовый каталог загрузок baseDir, склей с именем файла через filepath.Join и проверь через filepath.Clean, чтобы не было относительных переходов вроде ../».

Этот классический рецепт кочует из одного учебника в другой уже полтора десятка лет. Но в реальном продакшене он создает лишь обманчивую иллюзию безопасности. Простое лексическое манипулирование строками не учитывает физическую природу файловых систем. Стоит пользователю загрузить архив с символической ссылкой или устроить многопоточную гонку, как твой сервис услужливо прочитает приватные ключи сервера или файл /etc/passwd.

Чтобы закрыть эту фундаментальную брешь, авторы языка внедрили в стандартный пакет os новые низкоуровневые механизмы: функцию os.OpenInRoot и интерфейс os.Root. Но даже в этой реализации исследователи безопасности раскопали критическую уязвимость GO-2026-4970. Давай разберем эту механику до основания.

Ловушка симлинок и гонки TOCTOU

Почему привычная функция filepath.Join(baseDir, userInput) бессильна против изощренного обхода каталогов (Path Traversal)?

Здесь работают два классических вектора атаки:

  1. Скрытые символические ссылки (symlinks): представь, что внутри директории /var/data/uploads лежит файл avatar.png, который на самом деле является симлинкой, указывающей на /etc/shadow. Функция filepath.Join("/var/data/uploads", "avatar.png") вернет совершенно легитимную строку /var/data/uploads/avatar.png. С точки зрения строкового парсера путь находится строго внутри разрешенной папки! Но последующий вызов os.Open() прозрачно последует по ссылке и отдаст злоумышленнику хеши системных паролей.
  2. Гонки файловой системы (TOCTOU — Time-of-Check to Time-of-Use): опытные инженеры пытаются обойти проблему симлинок, вызывая filepath.EvalSymlinks перед открытием файла. Но в многопоточных веб-сервисах между моментом проверки пути и моментом физического вызова os.Open проходит несколько микросекунд. Злоумышленник в параллельном запросе успевает удалить проверенный файл и создать на его месте симлинку на системный корень. Проверка пройдена, файл открыт, система скомпрометирована.

Пытаться решить проблему синхронизации в пространстве пользователя (user space) бесполезно: безопасность должна гарантироваться ядром операционной системы в момент разрешения файлового дескриптора.

Системный уровень: как работает os.OpenInRoot

Методы os.OpenInRoot(dir, path) и os.OpenRoot(dir) переносят проверку границ с уровня манипуляций со строками на уровень системных вызовов ядра.

В современных версиях Linux (начиная с ядра 5.6) для этого используется системный вызов openat2 со специальным флагом RESOLVE_BENEATH. Этот флаг инструктирует виртуальную файловую систему ядра (VFS) блокировать любые попытки перехода выше каталога, заданного базовым дескриптором. Если путь содержит переход ../ или симлинку, ведущую во внешнюю файловую систему, ядро немедленно прерывает операцию с системной ошибкой EXDEV еще до того, как дескриптор файла будет создан.

Функция os.OpenInRoot(baseDir, name) предназначена для разовых операций: она открывает конкретный файл, отклоняя любые попытки побега, но честно разрешает внутренние симлинки, если они не покидают пределов baseDir.

Если же сервису требуется выполнить серию операций — создать подкаталог, записать временные файлы и прочитать метаданные, — используется метод os.OpenRoot(dir). Он возвращает объект *os.Root, удерживающий открытый дескриптор базовой папки и предоставляющий безопасные методы Open, Create, OpenFile, Remove и Stat.

Уязвимость GO-2026-4970: лазейка с завершающим слэшем

Казалось бы, защита ядра неуязвима. Однако летом 2026 года в стандартной библиотеке Go была обнаружена критическая брешь безопасности, зарегистрированная как GO-2026-4970 (CVE-2026-39822, Issue #79005).

Уязвимость проявлялась при крайне специфическом сочетании условий:

  • внутри корня os.Root существовала легитимная символическая ссылка на внешнюю директорию (например, shared -> /opt/shared/data);
  • пользователь передавал путь к этой ссылке с обязательным завершающим слэшем: root.Open("shared/").

Из-за тонкости разбора путей в рантайме Go наличие концевого слэша заставляло парсер ядра интерпретировать обращение не как к самому файлу ссылки, а как к каталогу, на который она указывает. В результате системный флаг RESOLVE_BENEATH обходился, и злоумышленник получал доступ к чтению файлов за пределами песочницы.

Команда Go оперативно закрыла уязвимость в корректирующих релизах тулчейна 1.25.12, 1.26.5 и предрелизной ветке 1.27.0-rc.2 (патч CL 797880). Если твои сервисы оперируют os.Root, обновление версии компилятора критически обязательно.

Рефакторинг кода: переход на os.OpenInRoot и os.OpenRoot

Давай посмотрим, как выглядит переход от уязвимого лексического кода к эталонному безопасному стилю Go:

package main

import (
    "fmt"
    "io"
    "os"
    "path/filepath"
)

// НЕБЕЗОПАСНО: уязвимо к симлинкам и гонкам TOCTOU
func unsafeReadFile(baseDir, userFilename string) ([]byte, error) {
    // filepath.Join не защитит от подложенных симлинок на /etc/passwd
    targetPath := filepath.Join(baseDir, userFilename)
    return os.ReadFile(targetPath)
}

// БЕЗОПАСНО: атомарное открытие через os.OpenInRoot
func safeReadSingleFile(baseDir, userFilename string) ([]byte, error) {
    // Ядро ОС гарантирует, что чтение не выйдет за границы baseDir
    file, err := os.OpenInRoot(baseDir, userFilename)
    if err != nil {
        return nil, fmt.Errorf("доступ запрещен или путь некорректен: %w", err)
    }
    defer file.Close()

    return io.ReadAll(file)
}

А вот пример безопасной работы с сессионной директорией через дескриптор os.Root:

// БЕЗОПАСНО: изолированная серия операций внутри директории
func safeProcessUploads(uploadDir, filename string, data []byte) error {
    root, err := os.OpenRoot(uploadDir)
    if err != nil {
        return fmt.Errorf("не удалось зафиксировать корень директории: %w", err)
    }
    defer root.Close()

    // Все файловые методы вызываются относительно открытого корня
    out, err := root.Create(filename)
    if err != nil {
        return fmt.Errorf("ошибка создания файла: %w", err)
    }
    defer out.Close()

    _, err = out.Write(data)
    return err
}

Документированные границы защиты os.Root

При внедрении os.Root важно четко понимать границы его ответственности. Это инструмент изоляции файловых путей, а не полноценный контейнер вроде Docker:

  • Точки монтирования: метод не блокирует переходы через смонтированные файловые системы (mount points и Linux bind mounts), если они физически расположены внутри доверенной директории.
  • Псевдофайловые системы: если в корень случайно смонтирован /proc или /dev, клиент сможет получить доступ к системным устройствам.
  • Сборка под WebAssembly: при компиляции под GOOS=js или GOOS=wasip1 аппаратная поддержка системных вызовов ядра отсутствует. Рантайм Go эмулирует проверку путей в пространстве пользователя, поэтому защита в браузере остается потенциально подверженной гонкам.
Минимальная версия тулчейна Go

Убедитесь, что билд-рантайм в ваших CI/CD пайплайнах использует версию Go не ниже 1.25.12 или 1.26.5. Использование os.OpenInRoot на более старых сборках оставляет лазейку обхода каталогов через концевой слэш в симлинках.

Переход на os.OpenInRoot навсегда закрывает целый пласт уязвимостей Zip Slip и Path Traversal. Забудь про filepath.Join при работе с пользовательскими данными — системные дескрипторы работают надежнее любых регулярных выражений.