Saltar al contenido
Development

Tres puertas que nunca estuvieron cerradas

Por Victor Da Luz
taurirustsecuritydev-loggreenhouse

Hace un tiempo, una revisión de seguridad de todo el repositorio de Greenhouse encontró dieciséis hallazgos. La mayoría eran errores: cosas que ya andaban mal, en silencio, esperando a que alguien las notara. Este lote es distinto. Nada aquí estaba roto. Tres cosas simplemente estaban sin cerrar, y nadie había cruzado ninguna de esas puertas todavía.

Puerta uno: sin política de seguridad de contenido alguna

Las apps de Tauri corren un webview real, y como cualquier webview, se le puede indicar qué tiene permitido cargar y ejecutar mediante un CSP. La configuración de Greenhouse tenía:

"security": { "csp": null }

Nulo significa sin política, lo cual significa sin respaldo. Hoy eso está genuinamente bien: el frontend no usa {@html}, no hace fetch a nada remoto, no carga scripts de terceros. Pero “bien hoy” es exactamente el tipo de frase que deja de ser cierta el día que alguien agregue un renderizador de markdown para notas de traspaso, o una función de “pegar una imagen” que haga fetch a una URL. Un CSP es un seguro barato contra que una función que todavía no existe termine dando acceso IPC completo a cualquier cosa que pueda meter una cadena de texto en el DOM. Se define así:

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

unsafe-inline solo en estilos, porque el servidor de desarrollo de Vite inyecta estilos con hot-reload en línea y se rompería sin eso. En producción de todas formas se sirve una sola hoja de estilos externa, así que la regla más laxa solo importa en desarrollo.

Puerta dos: un valor de configuración que se une a una ruta del sistema de archivos sin ninguna validación

Cada etapa del pipeline en Greenhouse tiene un prefijo de carpeta, 10-active, 20-explore, y así sucesivamente, y vienen de un archivo YAML que se puede editar a mano:

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

Path::join hace exactamente lo que cualquiera esperaría con una ruta absoluta o un ../: no se queda dentro de root, va adonde se le indique. Nadie iba a escribir folder_prefix: /etc por accidente. Pero “un archivo de configuración que solo edita una persona” y “un archivo de configuración que nadie valida” son dos posturas de seguridad distintas, y una errata o un mal merge en ese archivo habría empezado a escribir archivos del proyecto fuera del vault en silencio, en vez de fallar con un error.

La solución es una validación de cinco líneas, que corre una vez por etapa cada vez que se carga la configuración:

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(...)) }
}

Un único segmento de ruta relativa, o la carga falla de forma explícita en vez de reubicar los archivos en silencio.

Puerta tres: elegir la propia carpeta de inicio como vault

Configurar la raíz del vault solo verificaba una cosa: is_dir(). No absoluta, no canonicalizada, y nada impedía elegir ~ mismo en el explorador de carpetas, lo cual volcaría nueve directorios de nivel superior (10-active, 00-ideas, 90-vault, y así sucesivamente) directo en la carpeta de inicio real de la persona. No exactamente un agujero de seguridad. Un agujero de “la app acaba de desordenar la computadora”, que podría decirse es peor para la confianza.

Esta tenía una pregunta de diseño real escondida adentro: ¿qué se hace cuando alguien elige $HOME? El hallazgo original solo decía “advertir opcionalmente,” que es el tipo de instrucción fácil de sobreconstruir: en esta app no existe ningún sistema de toast o advertencia, y agregar uno solo para esto habría sido mucha superficie nueva para un problema de severidad baja. Decidí directamente en vez de construir alrededor del problema: bloqueo estricto sobre el directorio de inicio exacto, sin interfaz nueva. Cualquier otra carpeta, incluida una no vacía que se esté readoptando como vault existente, queda intacta.

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)
}

Pequeña, pura y completamente comprobable con pruebas unitarias sin tocar Tauri para nada, solo toma una ruta y un directorio de inicio opcional, y devuelve una ruta o un error.

Cómo verificar un CSP sin romper la propia confianza en la prueba

El cambio de CSP fue el que más me preocupó, porque un CSP demasiado estricto falla de una forma fácil de pasar por alto: la app simplemente se ve rota, o deja de cargar un script de forma sutil, y es posible no notarlo a menos que se esté mirando devtools directamente. Mirar npm run dev treinta segundos no es una verificación real.

En cambio recurrí a la configuración de WebDriver que la app ya tiene para manejar flujos reales de interfaz, y corrí el flujo de captura real de punta a punta contra un build con el nuevo CSP activo: clic real, llenado de formulario real, ida y vuelta de IPC real, pantalla de confirmación real. Si el CSP hubiera bloqueado la carga de un script o un estilo, ese flujo simplemente no se habría completado. Se completó, con la ruta de carpeta real volviendo desde una captura real. Esa es una afirmación mucho más sólida que “lo miré y parecía estar bien.”

Reflexión

Cada solución de este lote tiene la misma forma: algo que todavía no está mal, pero que solo se mantiene así porque nadie ha probado lo que no está defendiendo. Un CSP faltante no es un error hasta que una función futura necesite que lo sea. Una ruta de configuración sin validar no es un error hasta que alguien edite a mano la línea equivocada. Un vault en el directorio de inicio no es un error hasta que alguien lo elija de verdad en el explorador de carpetas. Este tipo de trabajo de endurecimiento no arregla nada visiblemente roto hoy, solo asegura que la app siga siendo honesta sobre sus promesas el día que algo cambie sobre lo que todavía no era honesta.

Lecturas relacionadas

Development

Cuando una app no puede quitar un permiso que ya otorgó

El scope del protocolo de assets de Tauri tiene un allow_directory y ningún revoke, así que cambiar de vault pasó a requerir un reinicio, cambiando el cambio instantáneo por una propiedad de seguridad que realmente se sostiene.

Leer
Development

Dos notas que nadie vio jamás

Greenhouse guardaba fielmente una nota de captura y una nota de traspaso, y no mostraba ninguna de las dos: una no tenía campo en el tipo de wire, la otra tenía un comando funcional que nadie llamaba.

Leer
Development

El escaneo que se ofreció a importarse a sí mismo

Un escaneo de importación que encontró la propia estructura interna de la app, y luego volvió a ofrecer una carpeta que ya había importado - dos versiones de la misma conversación faltante entre capas.

Leer