N’importe quelle crate dans votre arbre de dépendances peut ouvrir /etc/passwd, écrire dans votre répertoire ~/.ssh, ou énumérer chaque fichier de votre projet. La bibliothèque standard de Rust ne demande pas la permission. Elle part du principe que tout code capable d’appeler std::fs::File::open est autorisé à toucher n’importe quel chemin que le système d’exploitation permet.
cap-std remet en question ce postulat. C’est un remplacement direct des modules d’entrée/sortie std de Rust qui substitue l’autorité ambiante par des capabilities explicites. Si vous voulez ouvrir un fichier, vous avez d’abord besoin d’une capability qui prouve que vous avez accès au répertoire qui le contient.
C’est plus important que vous ne le pensez. Les attaques sur la chaîne d’approvisionnement n’ont pas toujours besoin d’exploits sophistiqués. Parfois, il suffit d’un script de build ou d’une dépendance transitive qui exfiltre discrètement des fichiers pendant l’exécution de vos tests. Le sandboxing de ce risque au niveau de la bibliothèque, sans containers ni VMs, c’est ce que cap-std rend possible.
À quoi ressemble concrètement l’accès au système de fichiers basé sur les capabilities
En Rust standard, ouvrir un fichier se fait en une ligne et sans prérequis :
use std::fs::File;
// Any code, anywhere, can do this
let file = File::open("/etc/passwd")?;
Le chemin est absolu et l’autorité est ambiante. Le système d’exploitation vérifie les permissions, mais le programme lui-même n’a jamais à démontrer qu’il aurait dû pouvoir construire ce chemin en premier lieu.
cap-std remplace cela par un modèle en deux étapes. Vous partez d’une capability, généralement un handle de répertoire, et vous ne pouvez ouvrir que des chemins relatifs à ce répertoire :
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")?;
Remarquez l’appel à open_ambient_dir. C’est la trappe de secours. Il convertit un chemin ambiant en capability, et il est volontairement verbeux et facile à auditer. Une fois que vous avez un Dir, il n’existe aucune méthode open qui accepte un chemin absolu. L’API ne l’autorise simplement pas.
Comment cap-std reflète std sans copier son modèle de sécurité
cap-std est structuré comme des remplacements directs de std::fs, std::net, et std::os::unix::net. Les types sont volontairement familiers. cap_std::fs::File encapsule std::fs::File. cap_std::fs::Dir est le nouveau point d’entrée, à peu près analogue à travailler dans un chroot, sauf que c’est garanti par le système de types plutôt que par un appel privilégié au système d’exploitation.
La crate y parvient grâce à une abstraction de plus bas niveau appelée cap-primitives. Sous le capot, cap-std utilise des appels système de type openat sur Unix et des opérations relatives restreintes similaires sur Windows. Elle n’appelle jamais le File::open sans restriction de la bibliothèque standard. Sous Linux, elle utilise openat2 avec RESOLVE_BENEATH quand c’est disponible pour empêcher les évasions par liens symboliques. Sous Windows, elle utilise NtCreateFile avec des flags restreints.
Ce n’est pas un wrapper autour de containers ou de seccomp. C’est une réimplémentation des APIs standard du système de fichiers qui omet simplement les opérations dangereuses.
Le type Dir prend en charge la plupart de ce à quoi vous vous attendriez : open, create_dir, rename, remove_file, read_dir, et ainsi de suite. La différence clé est que chaque opération est relative à ce handle de répertoire. Si vous voulez déplacer un fichier en dehors de l’arborescence, vous avez besoin de deux capabilities Dir, une pour la source et une pour la destination. L’API vous force à emporter avec vous la preuve d’accès.
Les astuces de portabilité qu’utilise cap-std
Les modèles de capabilities pour le système de fichiers varient selon les systèmes d’exploitation. Linux dispose de openat2. FreeBSD dispose de cap_rights_limit et O_RESOLVE_BENEATH. Windows dispose d’un contrôle d’accès basé sur les handles mais pas d’équivalent direct de RESOLVE_BENEATH. macOS offre le support le plus limité des principales plateformes.
cap-std gère cela grâce à une couche de portabilité. Sur les systèmes avec un support kernel solide, elle utilise les mécanismes de restriction natifs. Sur les systèmes qui n’en disposent pas, elle se rabat sur une implémentation en espace utilisateur qui résout les liens symboliques manuellement et valide que chaque composant d’un chemin relatif reste dans les limites de la capability.
Ce fallback est plus coûteux, mais cela signifie que votre modèle de sécurité est portable. Un sandbox WASI, un serveur Linux et le MacBook d’un développeur peuvent tous appliquer les mêmes limites de capabilities sans code spécifique à une plateforme dans votre application.
À quoi ressemble l’utilisation de cap-std dans un vrai projet
Convertir un projet existant est moins douloureux que vous ne le pensez, car les APIs sont volontairement proches de std. Le principal changement est que vous cessez de passer &Path ou &str comme unité d’adressage du système de fichiers. Vous passez &Dir à la place.
Voici un exemple simplifié de lecture de configuration depuis un répertoire contrôlé par l’appelant :
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)
}
La fonction load_config ne peut pas accéder aux fichiers en dehors du répertoire qui lui est donné. Elle n’a pas besoin de savoir où ce répertoire se trouve sur le disque. Cela la rend triviale à tester de manière isolée :
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 fournit un répertoire temporaire comme capability, donc même vos tests n’accordent jamais d’autorité ambiante. La fonction load_config échouerait si elle essayait d’ouvrir ../etc/passwd ou n’importe quel chemin absolu, quoi que fournissent le test ou l’appelant.
Ce qui casse quand vous basculez
La plus grande limitation est la compatibilité avec l’écosystème. La plupart des crates Rust qui touchent au système de fichiers acceptent un &Path ou un PathBuf. Elles s’attendent à une autorité ambiante. Si vous voulez utiliser cap-std avec, disons, une crate d’analyse de configuration qui lit des fichiers d’inclusion depuis le disque, cette crate doit être compatible avec cap-std ou vous devez lire les fichiers vous-même et passer le contenu textuel à l’analyseur.
C’est la même histoire de migration que pour le Rust asynchrone. De la même manière qu’une fonction prenant std::fs::File ne peut pas être appelée depuis du code asynchrone attendant un handle de fichier asynchrone, une fonction prenant &Path ne peut pas être appelée avec un &Dir. La frontière est nette mais réelle.
Il existe aussi des opérations que cap-std ne prend volontairement pas en charge. Vous ne pouvez pas créer de liens physiques qui sortent d’une arborescence de répertoires. Vous ne pouvez pas suivre de liens symboliques arbitraires qui pointent en dehors des limites de la capability. Vous ne pouvez pas appeler std::env::current_dir et supposer que cela signifie quoi que ce soit dans un programme basé sur les capabilities. Ce ne sont pas des bugs. C’est le modèle de sécurité.
Les performances ne posent généralement aucun problème. Sous Linux avec openat2, le surcoût se résume à quelques flags supplémentaires pour un appel système existant. Sur les plateformes utilisant le fallback en espace utilisateur, la résolution de chemin est plus lente, mais reste généralement négligeable comparée aux entrées/sorties réelles.
Quand cap-std vaut la friction
Vous n’avez probablement pas besoin de cap-std pour chaque outil en ligne de commande ou serveur web. Si vous faites confiance à vos dépendances et que votre modèle de menace concerne des attaquants externes plutôt que des crates malveillantes, les containers et les permissions normales du système d’exploitation suffisent.
cap-std brille dans quelques scénarios spécifiques :
Systèmes de plugins. Si votre application charge des modules WASM non fiables ou des plugins natifs, cap-std vous permet d’accorder à chaque plugin une capability Dir représentant son sandbox. Le plugin ne peut pas s’évader sans exploit kernel, car l’API n’exprime simplement pas le concept d’« ouvrir n’importe quel fichier ».
Outils de build et gestionnaires de paquets. Ils exécutent du code arbitraire provenant d’Internet. Un script build.rs qui utilise cap-std ne peut pas lire vos clés SSH à moins que vous ne lui remettiez explicitement une capability vers votre répertoire personnel.
Cibles WASI. La WebAssembly System Interface est construite sur les capabilities. La conception de l’API de cap-std a directement influencé WASI, et utiliser cap-std rend le portage vers WASI simple car le modèle de sécurité est déjà aligné.
Tests et reproductibilité. Les tests qui prennent &Dir au lieu de s’appuyer sur le répertoire de travail actuel sont hermétiques par défaut. Vous n’avez pas besoin de faire chdir et de restaurer l’état. Vous remettez simplement au test une capability de répertoire temporaire.
Pour commencer
Ajoutez cap-std à votre Cargo.toml :
[dependencies]
cap-std = "3.0"
cap-tempfile = "3.0"
Choisissez un module qui lit des fichiers, modifiez son API publique pour accepter &Dir au lieu de &Path, et mettez à jour les appelants. Vous n’êtes pas obligé de tout migrer d’un coup. cap-std et std::fs peuvent coexister dans le même binaire. La migration est opt-in par module.
Si vous maintenez une bibliothèque qui lit des fichiers, envisagez d’ajouter une API basée sur cap_std::fs::Dir à côté de votre API existante basée sur les chemins. Vos utilisateurs WASI vous en remercieront, et vos utilisateurs soucieux de sécurité auront plus facilement la possibilité de mettre leurs applications en sandbox.
Le dépôt cap-std se trouve sur github.com/bytecodealliance/cap-std. La documentation de la crate inclut des notes de support par plateforme et la surface d’API complète. Commencez par Dir::open_ambient_dir comme point d’entrée, puis essayez de supprimer chaque autre appel à std::fs qui accepte un chemin absolu. Vous serez surpris de l’autorité ambiante que votre code transportait.