Любой crate в дереве ваших зависимостей может открыть /etc/passwd, записать в ваш каталог ~/.ssh или перечислить каждый файл в вашем проекте. Стандартная библиотека Rust не запрашивает разрешения. Она предполагает, что любой код, способный вызвать std::fs::File::open, уполномочен обращаться к любому пути, который разрешает ОС.
cap-std меняет это предположение. Это прямая замена I/O модулей std в Rust, которая заменяет окружающее полномочие явными полномочиями. Если вы хотите открыть файл, сначала вам нужно полномочие, доказывающее, что у вас есть доступ к каталогу, содержащему его.
Это важнее, чем вы можете подумать. Атаки на цепочку поставок не всегда требуют сложных эксплойтов. Иногда им достаточно скрипта сборки или транзитивной зависимости, которая тихо эксфильтрует файлы, пока выполняются ваши тесты. Изоляция этого риска на уровне библиотеки, без контейнеров или ВМ, — это то, что предоставляет cap-std.
Как на самом деле выглядит доступ к файловой системе на основе полномочий
В стандартном Rust открытие файла — это одна строчка без каких-либо предварительных условий:
use std::fs::File;
// Any code, anywhere, can do this
let file = File::open("/etc/passwd")?;
Путь абсолютный, а полномочие — окружающее. Операционная система проверяет права доступа, но сама программа никогда не должна доказывать, что вообще имела право сконструировать этот путь.
cap-std заменяет это двухэтапной моделью. Вы начинаете с полномочия, обычно с дескриптора каталога, и можете открывать только пути относительно этого каталога:
use cap_std::fs::Dir;
use std::path::Path;
// Open the current working directory as a capability
let cwd = Dir::open_ambient_dir(".", cap_std::ambient_authority())?;
// Now we can only open files inside this directory tree
let file = cwd.open("config/app.toml")?;
Обратите внимание на вызов open_ambient_dir. Это аварийный выход. Он преобразует окружающий путь в полномочие, и он намеренно многословен и легко поддается аудиту. Получив Dir, вы не найдете метода open, который принимал бы абсолютный путь. API просто не позволяет этого.
Как cap-std отражает std, не копируя его модель безопасности
cap-std структурирован как прямая замена для std::fs, std::net и std::os::unix::net. Типы намеренно знакомы. cap_std::fs::File оборачивает std::fs::File. cap_std::fs::Dir — новая точка входа, примерно аналогичная работе внутри chroot, но с той разницей, что это обеспечивается системой типов, а не привилегированным вызовом ОС.
Крейт достигает этого через низкоуровневую абстракцию под названием cap-primitives. Внутри cap-std использует системные вызовы в стиле openat на Unix и аналогичные ограниченные относительные операции на Windows. Он никогда не вызывает неограниченный File::open стандартной библиотеки. На Linux он использует openat2 с RESOLVE_BENEATH, когда доступно, чтобы предотвратить обход через символические ссылки. На Windows он использует NtCreateFile с ограниченными флагами.
Это не обертка вокруг контейнеров или seccomp. Это переосмысление стандартных API файловой системы, которое просто опускает опасные операции.
Тип Dir поддерживает большую часть того, что вы ожидаете: open, create_dir, rename, remove_file, read_dir и так далее. Ключевое отличие в том, что каждая операция относительна дескриптора этого каталога. Если вы хотите переместить файл за пределы дерева, вам нужны два полномочия Dir: одно для источника и одно для назначения. API заставляет вас носить с собой доказательство доступа.
Уловки переносимости, которые использует cap-std
Модели полномочий файловой системы различаются между операционными системами. В Linux есть openat2. В FreeBSD есть cap_rights_limit и O_RESOLVE_BENEATH. В Windows есть управление доступом на основе дескрипторов, но нет прямого эквивалента RESOLVE_BENEATH. В macOS поддержка наиболее ограниченна среди основных платформ.
cap-std справляется с этим через слой переносимости. На системах с сильной поддержкой ядра он использует нативные механизмы ограничений. На системах без них он откатывается к реализации в userspace, которая вручную разрешает символические ссылки и проверяет, что каждый компонент относительного пути остается внутри границы полномочия.
Такой откат обходится дороже, но это означает, что ваша модель безопасности переносима. Песочница WASI, сервер Linux и MacBook разработчика могут все применять одни и те же границы полномочий без платформенно-специфичного кода в вашем приложении.
Как выглядит использование cap-std в реальном проекте
Переход существующего проекта менее болезнен, чем вы можете ожидать, потому что API намеренно близки к std. Основное изменение в том, что вы перестаете передавать &Path или &str в качестве единицы адресации файловой системы. Вместо этого вы передаете &Dir.
Вот упрощенный пример чтения конфигурации из каталога, которым управляет вызывающая сторона:
use cap_std::fs::Dir;
use std::io::{self, Read};
pub fn load_config(dir: &Dir, name: &str) -> io::Result<String> {
let mut file = dir.open(name)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
Функция load_config не может получить доступ к файлам за пределами переданного ей каталога. Ей не нужно знать, где этот каталог находится на диске. Это делает изолированное тестирование тривиальным:
use cap_std::fs::Dir;
use cap_tempfile::TempDir;
#[test]
fn test_load_config() -> std::io::Result<()> {
let tmp = TempDir::new(Default::default())?;
let dir = tmp.dir();
{
let mut f = dir.create("app.toml")?;
std::io::Write::write_all(&mut f, b"key = \"value\"")?;
}
let config = load_config(dir, "app.toml")?;
assert!(config.contains("value"));
Ok(())
}
cap-tempfile предоставляет временный каталог в виде полномочия, так что даже ваши тесты никогда не предоставляют окружающее полномочие. Функция load_config завершилась бы с ошибкой, если бы попыталась открыть ../etc/passwd или любой абсолютный путь, независимо от того, что предоставляет тест или вызывающая сторона.
Что ломается при переходе
Самое большое ограничение — совместимость с экосистемой. Большинство Rust-крейтов, которые работают с файловой системой, принимают &Path или PathBuf. Они ожидают окружающее полномочие. Если вы хотите использовать cap-std, скажем, с крейтом парсинга конфигурации, который читает include-файлы с диска, этот крейт должен поддерживать cap-std, или вам нужно самостоятельно читать файлы и передавать строковое содержимое парсеру.
Это та же история миграции, что и с async Rust. Точно так же, как функция, принимающая std::fs::File, не может быть вызвана из async-кода, ожидающего асинхронный файловый дескриптор, функция, принимающая &Path, не может быть вызвана с &Dir. Граница чистая, но реальная.
Есть также операции, которые cap-std намеренно не поддерживает. Вы не можете создавать жесткие ссылки, выходящие за пределы дерева каталогов. Вы не можете следовать произвольным символическим ссылкам, указывающим за пределы границы полномочия. Вы не можете вызывать std::env::current_dir и предполагать, что это что-то значит в программе на основе полномочий. Это не баги. Это модель безопасности.
Производительность обычно не является проблемой. На Linux с openat2 накладные расходы — несколько дополнительных флагов для существующего syscall. На платформах, использующих реализацию в userspace, разрешение путей медленнее, но обычно все еще пренебрежимо мало по сравнению с самим вводом-выводом.
Когда cap-std стоит трений
Вероятно, вам не нужен cap-std для каждого CLI-инструмента или веб-сервера. Если вы доверяете своим зависимостям и ваша модель угроз — внешние злоумышленники, а не вредоносные крейты, контейнеры и обычные права ОС вполне подходят.
cap-std проявляет себя в нескольких конкретных сценариях:
Системы плагинов. Если ваше приложение загружает ненадежные WASM-модули или нативные плагины, cap-std позволяет предоставить каждому плагину полномочие Dir, представляющее его песочницу. Плагин не может выбраться наружу без эксплойта ядра, потому что API просто не выражает концепцию «открыть любой файл».
Инструменты сборки и менеджеры пакетов. Они выполняют произвольный код из интернета. Скрипт build.rs, использующий cap-std, не может прочитать ваши SSH-ключи, если вы явно не передадите ему полномочие на ваш домашний каталог.
Целевые платформы WASI. WebAssembly System Interface построен на полномочиях. Дизайн API cap-std напрямую повлиял на WASI, и использование cap-std делает портирование на WASI простым, потому что модель безопасности уже согласована.
Тестирование и воспроизводимость. Тесты, которые принимают &Dir вместо того, чтобы полагаться на текущий рабочий каталог, по умолчанию герметичны. Вам не нужно вызывать chdir и восстанавливать состояние. Вы просто передаете тесту полномочие временного каталога.
Начало работы
Добавьте cap-std в ваш Cargo.toml:
[dependencies]
cap-std = "3.0"
cap-tempfile = "3.0"
Выберите один модуль, который читает файлы, измените его публичный API, чтобы он принимал &Dir вместо &Path, и обновите вызывающие стороны. Вам не нужно мигрировать все сразу. cap-std и std::fs могут сосуществовать в одном бинарнике. Миграция опциональна для каждого модуля.
Если вы поддерживаете библиотеку, которая читает файлы, рассмотрите возможность добавить API на основе cap_std::fs::Dir наряду с существующим, основанным на путях. Ваши пользователи WASI скажут вам спасибо, а ваши пользователи, заботящиеся о безопасности, смогут легче изолировать свои приложения.
Репозиторий cap-std находится по адресу github.com/bytecodealliance/cap-std. Документация крейта включает заметки о поддержке платформ и полную поверхность API. Начните с Dir::open_ambient_dir в качестве точки входа, а затем попробуйте убрать все остальные вызовы std::fs, которые принимают абсолютный путь. Вы удивитесь, сколько окружающего полномочия носил с собой ваш код.