Pular para o conteúdo
Development

Três portas que nunca foram trancadas

Por Victor Da Luz
taurirustsecuritydev-loggreenhouse

Uma revisão de segurança em todo o repositório do Greenhouse trouxe dezesseis achados um tempo atrás. A maioria era bug: coisas que já estavam se comportando mal, silenciosamente, esperando para serem notadas. Esse lote é diferente. Nada aqui estava quebrado. Três coisas estavam só destrancadas, e ninguém tinha passado por nenhuma das portas ainda.

Porta um: nenhuma política de segurança de conteúdo

Apps Tauri rodam uma webview de verdade, e como qualquer webview, você consegue dizer a ela o que ela pode carregar e executar via um CSP. A config do Greenhouse tinha:

"security": { "csp": null }

Nulo significa nenhuma política, o que significa nenhuma rede de proteção. Hoje isso é genuinamente ok: o frontend não usa {@html}, não busca nada remoto, não carrega scripts de terceiros. Mas “ok hoje” é exatamente o tipo de frase que para de ser verdade no dia em que alguém adiciona um renderizador de markdown para notas de handoff, ou uma funcionalidade de “colar uma imagem” que busca uma URL. Um CSP é um seguro barato contra uma funcionalidade que ainda não existe se transformar em acesso total via IPC para qualquer coisa que consiga colocar uma string no DOM. Defina como:

"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"

unsafe-inline só em estilos, porque o servidor de dev do Vite injeta estilos com hot-reload inline e quebraria sem isso, produção envia uma única folha de estilos externa de qualquer jeito, então a regra mais solta só importa em dev.

Porta dois: um valor de config unido a um caminho de arquivo sem nenhuma checagem

Cada estágio do pipeline no Greenhouse tem um prefixo de pasta, 10-active, 20-explore, e assim por diante, e eles vêm de um arquivo YAML que o usuário pode editar à mão:

pub fn stage_dir(root: &Path, stage: &StageConfig) -> PathBuf {
    root.join(&stage.folder_prefix)
}

Path::join faz exatamente o que você imaginaria com um caminho absoluto ou um ../, não fica dentro de root, vai para onde você mandou ir. Ninguém ia digitar folder_prefix: /etc por acidente. Mas “um arquivo de config que só você edita” e “um arquivo de config que nada valida” são duas posturas de segurança diferentes, e um erro de digitação ou um merge ruim nesse arquivo teria começado a escrever arquivos de projeto silenciosamente fora do vault em vez de dar erro.

A correção é uma checagem de cinco linhas, rodada uma vez por estágio toda vez que a config carrega:

fn validate_folder_prefix(prefix: &str) -> crate::Result<()> {
    let valid = !prefix.is_empty()
        && !prefix.contains('/')
        && !prefix.contains('\\')
        && prefix != "."
        && prefix != "..";
    if valid { Ok(()) } else { Err(EngineError::Invalid(...)) }
}

Um único segmento de caminho relativo, ou o carregamento falha alto em vez de silenciosamente realocar seus arquivos.

Porta três: escolher sua própria pasta home como o vault

Definir a raiz do vault checava só uma coisa: is_dir(). Não absoluto, não canonicalizado, e nada impedindo você de escolher ~ mesmo no navegador de pastas, o que despejaria nove diretórios de nível superior (10-active, 00-ideas, 90-vault, e assim por diante) direto na sua pasta home de verdade. Não exatamente um buraco de segurança. Um buraco de “o app acabou de bagunçar seu computador”, o que é discutivelmente pior para a confiança.

Esse tinha uma pergunta real de design escondida nele: o que você faz quando alguém escolhe $HOME? O achado original só dizia “opcionalmente avisar”, que é o tipo de instrução fácil de superconstruir, não existe nenhum sistema de toast ou aviso nesse app, e adicionar um só para isso seria muita superfície nova para um problema de severidade baixa. Tomei a decisão diretamente em vez de construir em torno dela: bloqueio total no diretório home exato, nenhuma UI nova. Qualquer outra pasta, incluindo uma não-vazia que você esteja readotando como um vault existente, permanece intocada.

fn resolve_vault_root(path: &Path, home: Option<&Path>) -> crate::Result<PathBuf> {
    if !path.is_absolute() { return Err(...) }
    let canonical = path.canonicalize().map_err(...)?;
    if !canonical.is_dir() { return Err(...) }
    if Some(canonical.as_path()) == home { return Err(...) }
    Ok(canonical)
}

Pequeno, puro, e totalmente testável por unidade sem tocar em Tauri de forma alguma, ele só recebe um caminho e um diretório home opcional e devolve um caminho ou um erro.

Como verificar um CSP sem quebrar sua própria confiança no teste

A mudança de CSP foi a que mais me preocupou, porque um CSP rígido demais falha de um jeito fácil de perder: o app simplesmente parece quebrado, ou sutilmente não carrega um script, e você pode não notar a menos que esteja de olho no devtools. Olhar o npm run dev por trinta segundos não é uma checagem real.

Em vez disso, recorri à configuração de WebDriver que esse app já tem para dirigir fluxos reais de UI, e rodei o fluxo de captura real de ponta a ponta contra um build com o novo CSP em vigor, clique real, preenchimento de formulário real, ida e volta real de IPC, tela de confirmação real. Se o CSP tivesse bloqueado um script ou um estilo de carregar, esse fluxo simplesmente não teria completado. Completou, com o caminho de pasta real voltando de uma captura real. Isso é uma afirmação muito mais forte do que “eu olhei e pareceu bom”.

Reflexão

Cada correção nesse lote tem a mesma forma: algo que ainda não está errado, mas só continua assim porque ninguém tentou a coisa contra a qual ele não se defende. Um CSP faltando não é um bug até uma funcionalidade futura precisar que seja um. Um caminho de config não validado não é um bug até alguém editar à mão a linha errada. Um vault no diretório home não é um bug até alguém realmente escolher isso no navegador de pastas. Um trabalho de hardening como esse não corrige nada visivelmente quebrado hoje, ele só garante que o app continue honesto sobre suas promessas no dia em que algo mudar sobre o que ele ainda não era honesto.

Leitura relacionada

Development

O scan que se ofereceu para importar a si mesmo

Um scanner de importação que encontrou a própria estrutura interna do app, e depois voltou a oferecer uma pasta que tinha acabado de importar, duas versões da mesma conversa que faltava entre camadas.

Ler
Development

A pasta que ficou parada

Uma revisão de código encontrou um invariante que a feature de adotar/importar do Greenhouse quebrava silenciosamente, e a correção que tornou tudo chato de novo.

Ler

Você também pode achar útil

Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais
RackNerd

RackNerd VPS

Hospedagem VPS econômica para serviços leves que funcionam continuamente.

Como afiliado da RackNerd, ganho com compras qualificadas.

Saiba mais