Qualquer crate na sua árvore de dependências pode abrir /etc/passwd, escrever no seu diretório ~/.ssh ou enumerar todos os arquivos do seu projeto. A biblioteca padrão do Rust não pede permissão. Ela assume que qualquer código com a capacidade de chamar std::fs::File::open está autorizado a acessar qualquer caminho que o SO permita.
cap-std muda essa suposição. É um substituto direto para os modules de I/O da std do Rust que substitui a autoridade ambiente por capabilities explícitas. Se você quer abrir um arquivo, primeiro precisa de uma capability que prove que você tem acesso ao diretório que o contém.
Isso importa mais do que você pode imaginar. Ataques à cadeia de suprimentos nem sempre precisam de exploits sofisticados. Às vezes, basta um script de build ou uma dependência transitiva que exfiltra arquivos silenciosamente enquanto seus testes rodam. Isolar esse risco em um sandbox no nível da biblioteca, sem containers ou VMs, é o que cap-std possibilita.
Como o acesso ao sistema de arquivos baseado em capabilities funciona na prática
Em Rust padrão, abrir um arquivo é uma linha só, sem pré-requisitos:
use std::fs::File;
// Any code, anywhere, can do this
let file = File::open("/etc/passwd")?;
O caminho é absoluto e a autoridade é ambiente. O sistema operacional verifica as permissões, mas o programa em si nunca precisa demonstrar que deveria ter sido capaz de construir aquele caminho em primeiro lugar.
cap-std substitui isso por um modelo de duas etapas. Você começa com uma capability, normalmente um handle de diretório, e só pode abrir caminhos relativos a esse diretório:
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")?;
Repare na chamada a open_ambient_dir. Essa é a saída de emergência. Ela converte um caminho ambiente em uma capability e é deliberadamente verbosa e fácil de auditar. Uma vez que você tem um Dir, não existe método open que aceite um caminho absoluto. A API simplesmente não permite.
Como cap-std espelha a std sem copiar seu modelo de segurança
cap-std é estruturada como substitutos diretos para std::fs, std::net e std::os::unix::net. Os tipos são intencionalmente familiares. cap_std::fs::File encapsula std::fs::File. cap_std::fs::Dir é o novo ponto de entrada, mais ou menos análogo a trabalhar dentro de um chroot, exceto que é aplicado pelo sistema de tipos em vez de uma chamada privilegiada ao SO.
A crate consegue isso por meio de uma abstração de nível inferior chamada cap-primitives. Nos bastidores, cap-std usa chamadas de sistema no estilo openat no Unix e operações restritas relativas similares no Windows. Ela nunca chama a File::open irrestrita da biblioteca padrão. No Linux, usa openat2 com RESOLVE_BENEATH quando disponível para impedir escapes por symlink. No Windows, usa NtCreateFile com flags restritas.
Isso não é um wrapper em torno de containers ou seccomp. É uma reimplementação das APIs padrão de sistema de arquivos que simplesmente omite as operações perigosas.
O tipo Dir suporta a maioria do que você esperaria: open, create_dir, rename, remove_file, read_dir e assim por diante. A diferença fundamental é que toda operação é relativa a esse handle de diretório. Se você quer mover um arquivo para fora da árvore, precisa de duas capabilities Dir, uma para a origem e outra para o destino. A API te obriga a carregar a prova de acesso com você.
Os truques de portabilidade que cap-std usa
Os modelos de capabilities de sistema de arquivos variam entre sistemas operacionais. O Linux tem openat2. O FreeBSD tem cap_rights_limit e O_RESOLVE_BENEATH. O Windows tem controle de acesso baseado em handle, mas nenhum equivalente direto de RESOLVE_BENEATH. O macOS tem o suporte mais limitado entre as principais plataformas.
cap-std lida com isso por meio de uma camada de portabilidade. Em sistemas com suporte forte do kernel, ela usa os mecanismos de restrição nativos. Em sistemas sem eles, recorre a uma implementação em userspace que resolve symlinks manualmente e valida que cada componente de um caminho relativo permanece dentro do capability boundary.
Esse fallback é mais caro, mas significa que seu modelo de segurança é portátil. Um sandbox WASI, um servidor Linux e o MacBook de um desenvolvedor podem todos aplicar os mesmos capability boundaries sem código específico de plataforma na sua aplicação.
Como é usar cap-std em um projeto real
Converter um projeto existente é menos doloroso do que você pode esperar, porque as APIs são deliberadamente próximas da std. A mudança principal é que você para de passar &Path ou &str como unidade de endereçamento do sistema de arquivos. Você passa &Dir no lugar.
Aqui está um exemplo simplificado de leitura de configuração a partir de um diretório que o chamador controla:
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)
}
A função load_config não pode acessar arquivos fora do diretório que lhe é passado. Ela não precisa saber onde esse diretório está no disco. Isso torna trivial testá-la de forma isolada:
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 fornece um diretório temporário como uma capability, então até mesmo seus testes nunca concedem autoridade ambiente. A função load_config falharia se tentasse abrir ../etc/passwd ou qualquer caminho absoluto, independentemente do que o teste ou o chamador forneça.
O que quebra quando você muda
A maior limitação é a compatibilidade com o ecossistema. A maioria das crates Rust que toca o sistema de arquivos aceita &Path ou PathBuf. Elas esperam autoridade ambiente. Se você quer usar cap-std com, digamos, uma crate de parsing de configuração que lê arquivos de include do disco, essa crate precisa ser cap-std-aware ou você precisa ler os arquivos você mesmo e passar o conteúdo em string para o parser.
Essa é a mesma história de migration do Rust assíncrono. Da mesma forma que uma função que recebe std::fs::File não pode ser chamada de código assíncrono esperando um handle de arquivo assíncrono, uma função que recebe &Path não pode ser chamada com um &Dir. O boundary é limpo, mas real.
Também existem operações que cap-std deliberadamente não suporta. Você não pode criar hard links que escapem de uma árvore de diretórios. Você não pode seguir symlinks arbitrários que apontam para fora do capability boundary. Você não pode chamar std::env::current_dir e assumir que isso significa alguma coisa em um programa baseado em capabilities. Isso não são bugs. São o security model.
Performance geralmente não é um problema. No Linux com openat2, o overhead são algumas flags extras em uma syscall existente. Em plataformas que usam o fallback de userspace, a resolução de caminho é mais lenta, mas normalmente ainda é negligenciável comparada ao I/O real.
Quando cap-std vale o atrito
Você provavelmente não precisa de cap-std para toda ferramenta de CLI ou servidor web. Se você confia nas suas dependências e seu threat model é atacantes externos em vez de crates maliciosos, containers e permissões normais do SO são suficientes.
cap-std brilha em alguns cenários específicos:
Sistemas de plugin. Se sua aplicação carrega modules WASM não confiáveis ou plugins nativos, cap-std permite que você conceda a cada plugin uma capability Dir representando seu sandbox. O plugin não pode escapar sem um exploit do kernel, porque a API simplesmente não expressa o conceito de “abrir qualquer arquivo”.
Ferramentas de build e gerenciadores de packages. Eles executam código arbitrário da internet. Um script build.rs que usa cap-std não pode ler suas chaves SSH a menos que você explicitamente lhe entregue uma capability para o seu diretório home.
Alvos WASI. A WebAssembly System Interface é construída sobre capabilities. O design da API de cap-std influenciou diretamente a WASI, e usar cap-std torna o port para WASI direto porque o security model já está alinhado.
Testes e reprodutibilidade. Testes que recebem &Dir em vez de depender do diretório de trabalho atual são herméticos por padrão. Você não precisa fazer chdir e restaurar o estado. Você simplesmente entrega ao teste uma capability de diretório temporário.
Primeiros passos
Adicione cap-std ao seu Cargo.toml:
[dependencies]
cap-std = "3.0"
cap-tempfile = "3.0"
Escolha um module que lê arquivos, mude sua API pública para aceitar &Dir em vez de &Path e atualize os chamadores. Você não precisa migrar tudo de uma vez. cap-std e std::fs podem coexistir no mesmo binário. A migration é opt-in por module.
Se você mantém uma biblioteca que lê arquivos, considere adicionar uma API baseada em cap_std::fs::Dir ao lado da sua API baseada em caminho existente. Seus usuários de WASI vão agradecer, e seus usuários preocupados com segurança vão ter mais facilidade em sandboxear suas aplicações.
O repository de cap-std está em github.com/bytecodealliance/cap-std. A documentação da crate inclui notas de suporte de plataforma e a superfície completa da API. Comece com Dir::open_ambient_dir como ponto de entrada, depois tente remover todas as outras chamadas a std::fs que aceitem um caminho absoluto. Você vai se surpreender com a quantidade de autoridade ambiente que seu código estava carregando.