Back to sh0
sh0

Auditamos nuestra propia plataforma y encontramos 88 problemas de seguridad

Realizamos 4 auditorias de seguridad exhaustivas en nuestro propio PaaS y encontramos 88 problemas -- 9 criticos, 12 altos, 45 medios. Aqui esta cada hallazgo, cada correccion y lo que aprendimos.

Thales & Claude | March 30, 2026 15 min sh0
EN/ FR/ ES
securityauditrustvulnerabilitypaasowasphardening

El 12 de marzo de 2026 -- doce dias despues de empezar a construir sh0.dev -- dejamos de escribir funcionalidades y auditamos todo lo que habiamos construido hasta el momento. No una revision superficial. Una auditoria de seguridad sistematica, fase por fase, de todo el codebase: gestor de proxy, pipeline de despliegue, modulo de auth, monitorizacion, motor de respaldos, dashboard, gestion de compose, RBAC, entornos de preview, hooks de despliegue, infraestructura como codigo, escalado horizontal y monitorizacion de uptime.

Encontramos 88 problemas. Nueve eran criticos. Doce eran de alta severidad. Cuarenta y cinco eran medios. Veintidos eran bajos.

Este no es un articulo sobre lo seguro que es sh0. Es un articulo sobre lo que encontramos, como lo corregimos, y por que auditar tu propio codigo -- de forma brutal, sistematica, antes que nadie mas -- es una de las actividades de mayor impacto en ingenieria de software.


Las cuatro rondas de auditoria

Dividimos la auditoria en cuatro rondas, cada una cubriendo un conjunto de fases de implementacion:

RondaFases cubiertasAlcanceHallazgos
1Fases 1-6Infraestructura central (Docker, Git, base de datos, contenedores)Cubierto en sesiones anteriores
2Fases 7-12Proxy, Pipeline de despliegue, Auth, Monitor, Respaldos, Dashboard88 hallazgos
3Fases 13-19Alertas, RBAC, Templates, Compose, i18n45 hallazgos
4Fases 20-25Compose V2, Entornos Preview, Hooks de despliegue, IaC, Escalado, Uptime51 hallazgos

La Ronda 2 produjo los 88 hallazgos que dan titulo a este articulo. Las Rondas 3 y 4 anadieron otros 96 hallazgos en fases posteriores. Cada hallazgo critico y alto de todas las rondas fue corregido antes de avanzar. El enfoque fue el mismo cada vez: enumerar hallazgos por severidad, paralelizar las correcciones en conjuntos de archivos independientes, ejecutar la suite completa de pruebas y verificar compilacion limpia.


Los 88 hallazgos: desglose por fase

Aqui es donde aterrizaron los hallazgos de la Ronda 2:

FaseComponenteCriticoAltoMedioBajoTotal
7Gestor de Proxy347418
8Pipeline de despliegue1514626
9Modulo Auth3111520
10Monitor00123
11Motor de respaldos12418
12Dashboard00538
--Integracion10315
Total912452288

La Fase 8 (Pipeline de despliegue) tuvo mas hallazgos -- 26. Esto tiene sentido: los pipelines de despliegue tocan entrada de usuario, comandos shell, sistemas de archivos, peticiones de red y orquestacion de contenedores. Cada superficie es una superficie de ataque.


Los nueve hallazgos criticos

1. Inyeccion de comandos en respaldo de base de datos (Fase 11)

El motor de respaldos interpolaba el parametro db_name directamente en comandos shell pasados a pg_dump, mysqldump y mongodump. Una base de datos llamada test; rm -rf / ejecutaria comandos arbitrarios dentro del contenedor.

rust// ANTES: vulnerable a inyeccion
let cmd = format!("pg_dump -U postgres {}", db_name);
Command::new("sh").arg("-c").arg(&cmd).output()?;

// DESPUES: validacion estricta antes de cualquier construccion de comando
fn validate_db_name(name: &str) -> Result<(), BackupError> {
    if name.is_empty() || name.len() > 128 {
        return Err(BackupError::InvalidInput("Longitud de nombre de base de datos invalida".into()));
    }
    if !name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
        return Err(BackupError::InvalidInput("El nombre de base de datos contiene caracteres invalidos".into()));
    }
    Ok(())
}

Esta fue la primera correccion que implementamos. La inyeccion de comandos en un PaaS es la peor vulnerabilidad posible -- da a los atacantes ejecucion de codigo arbitrario en el host.

2. Handler WebSocket sin autenticacion (Integracion)

El handler WebSocket stream_logs aceptaba conexiones sin extraer ni verificar AuthUser. Cualquier peticion -- autenticada o no -- podia transmitir los logs de cualquier aplicacion. Los logs a menudo contienen variables de entorno, consultas de base de datos y mensajes de error con contexto sensible.

La correccion: extraer y verificar el JWT antes de actualizar la conexion HTTP a WebSocket. Los tokens invalidos o ausentes reciben un 401 antes de que se complete el handshake de actualizacion.

3. Ataque de timing en comparacion de claves API (Fase 9)

Usamos == para comparar hashes de claves API. Como se discutio en el Articulo 9, esto filtra informacion a traves del timing de respuesta. La correccion: subtle::ConstantTimeEq para todas las comparaciones de hashes.

4-5. Sin limitacion de tasa en login/TOTP + codigos de respaldo no almacenados (Fase 9)

Los intentos de login ilimitados hacen el fuerza bruta trivial. Los intentos TOTP ilimitados hacen que el espacio de codigos de 6 digitos (1.000.000 de posibilidades) sea descifrable en minutos. Construimos un limitador de tasa en memoria con ventana deslizante: 5 intentos de login por 15 minutos, 5 intentos TOTP por 5 minutos.

El problema de los codigos de respaldo fue igualmente critico: el endpoint de configuracion TOTP generaba 10 codigos de respaldo y los devolvia al usuario, pero nunca los almacenaba en la base de datos. Si un usuario perdia su autenticador, los codigos que habia anotado eran inutiles. Anadimos una columna backup_codes_hash y hashing Argon2id para cada codigo de respaldo, con consumo de un solo uso al hacer login.

6-8. SSRF via URLs no validadas (Fase 7)

Tres hallazgos relacionados en el gestor de proxy. La URL de admin de Caddy, las direcciones upstream y las configuraciones de dominio aceptaban entrada arbitraria sin validacion. Un atacante podia apuntar el proxy a http://169.254.169.254 (endpoint de metadatos de instancia cloud) o servicios internos.

rust// Validar URL de admin: solo debe ser localhost
fn validate_admin_url(url: &str) -> Result<(), ProxyError> {
    let parsed = Url::parse(url).map_err(|_| ProxyError::InvalidUrl)?;
    match parsed.host_str() {
        Some("localhost") | Some("127.0.0.1") | Some("::1") => Ok(()),
        _ => Err(ProxyError::InvalidUrl),
    }
}

// Validar upstream: debe ser rango de IP privada
fn is_private_ip(ip: &IpAddr) -> bool {
    match ip {
        IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
        IpAddr::V6(v6) => v6.is_loopback(),
    }
}

9. Sin bloqueo de despliegues concurrentes (Fase 8)

Dos despliegues simultaneos a la misma aplicacion podian causar conflictos de puertos, colisiones de nombres de contenedores y estado corrupto. Anadimos un bloqueo de despliegue por app usando DashMap<String, Arc<Mutex<()>>> -- un mapa hash concurrente donde cada ID de app se mapea a su propio mutex. El bloqueo se mantiene durante la duracion del despliegue y se libera automaticamente via RAII.


Los doce hallazgos de alta severidad

Los hallazgos de alta severidad no otorgarian ejecucion de codigo inmediata pero podrian llevar a exposicion de datos, interrupcion del servicio o escalada de privilegios.

Traversal de ruta en almacenamiento de respaldos -- El almacenamiento local de respaldos resolvia rutas de archivos con self.base_dir.join(key) sin canonicalizacion. Una clave como ../../etc/passwd podia escapar del directorio de respaldos. Correccion: canonicalizar la ruta resuelta y verificar que empieza con base_dir.

Logs de build exponiendo secretos -- La salida de build de Docker se transmitia y almacenaba sin redactar variables de entorno. Un log de build para una aplicacion con DATABASE_URL configurado contendria la cadena de conexion completa de la base de datos, accesible para cualquiera con permisos de despliegue. Construimos un filtro de redaccion basado en regex:

rustfn redact_secrets(line: &str) -> String {
    let re = regex::Regex::new(
        r"(?i)([\w]*(?:KEY|SECRET|PASSWORD|TOKEN|CREDENTIAL|AUTH)[\w]*\s*=\s*)\S+"
    ).unwrap();
    re.replace_all(line, "${1}***REDACTED***").to_string()
}

Enumeracion de usuarios por timing -- Los intentos de login para usuarios inexistentes retornaban mas rapido que los intentos para usuarios reales con contrasenas incorrectas (porque el hashing Argon2id se saltaba). Correccion: siempre ejecutar una comparacion de hash ficticia.

Sin limite de tamano de payload de webhook -- Los endpoints de webhook de GitHub y GitLab aceptaban cuerpos POST sin limites. Un payload artesanal podia agotar la memoria del servidor. Correccion: DefaultBodyLimit::max(1_048_576) (1 MB) en rutas de webhook.

Llamadas unwrap en handlers -- Aproximadamente 25 llamadas .unwrap() en serde_json::to_value() a traves de 7 archivos de handlers. Cada una era un panic potencial en produccion. Las reemplazamos todas con un helper to_json() que devuelve ApiError::Internal en fallo de serializacion.

Los hallazgos altos restantes incluian: argumentos de build Docker no sanitizados, credenciales de base de datos codificadas en el modulo de dump de respaldos, profundidad de clone git ilimitada, sin validacion de correo ACME, sin limites de tamano de cuerpo en la API general y configuraciones de Caddy no validadas.


Rondas 3 y 4: mas fases, mas hallazgos

Ronda 3: Fases 13-19 (45 hallazgos, 27 corregidos)

Los hallazgos mas significativos en esta ronda:

  • SSRF en webhooks de alertas: las URLs de despacho de webhooks podian apuntar a rangos de IP privadas. Anadimos el mismo rechazo de IP privada usado en el modulo de proxy.
  • Inyeccion HTML en correos de alertas: los campos controlados por el usuario (nombre de app, descripcion de alerta) se interpolaban en cuerpos de correo HTML sin escapar.
  • Inyeccion de cabeceras SMTP: saltos de linea en campos de asunto de correo podian inyectar cabeceras adicionales.
  • Traversal de ruta en montaje de volumenes: las configuraciones de Docker Compose podian montar rutas del host, potencialmente exponiendo el sistema de archivos del host.
  • Proteccion contra bomba YAML: sin limite de tamano en archivos YAML de Compose. Un YAML de 10 MB con anclajes profundamente anidados podia consumir gigabytes de memoria durante el parsing. Correccion: limite duro de 256 KB.
  • Aplicacion RBAC faltante: varios endpoints verificaban autenticacion pero no autorizacion -- un viewer podia realizar acciones de nivel developer.

Ronda 4: Fases 20-25 (51 hallazgos, 37 corregidos)

Los hallazgos criticos aqui se centraron en dos temas:

Inyeccion de comandos en cron y hooks de despliegue -- Las definiciones de tareas cron y los comandos de hooks de despliegue aceptaban entrada shell arbitraria. Construimos validate_command():

rustconst FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '`', '>', '<', '\n', '\r'];
const FORBIDDEN_PATTERNS: &[&str] = &["$(", "${"];

pub fn validate_command(cmd: &str) -> Result<(), ApiError> {
    if cmd.len() > 4096 {
        return Err(ApiError::BadRequest("Comando demasiado largo".into()));
    }
    for ch in FORBIDDEN_CHARS {
        if cmd.contains(*ch) {
            return Err(ApiError::BadRequest(
                format!("El comando contiene caracter prohibido: {}", ch)
            ));
        }
    }
    for pattern in FORBIDDEN_PATTERNS {
        if cmd.contains(pattern) {
            return Err(ApiError::BadRequest(
                format!("El comando contiene patron prohibido: {}", pattern)
            ));
        }
    }
    Ok(())
}

SSRF en monitorizacion de uptime -- Las URLs de verificacion de uptime podian apuntar a direcciones IP privadas, convirtiendo el sistema de monitorizacion en un proxy SSRF. Implementamos rechazo exhaustivo de IP privada cubriendo rangos RFC 1918, direcciones link-local, CGNAT (100.64.0.0/10) y loopback.


El proceso de correccion: equipos paralelos, cero superposicion de archivos

Con 88 hallazgos que corregir solo en la Ronda 2, la remediacion secuencial no era opcion. Organizamos las correcciones en cuatro equipos paralelos, cada uno responsable de un conjunto de crates sin archivos superpuestos:

  • Equipo A: sh0-proxy (4 correcciones, 15 nuevas pruebas)
  • Equipo B: sh0-auth + sh0-db (3 correcciones, 9 nuevas pruebas)
  • Equipo C: sh0-backup (5 correcciones, 13 nuevas pruebas)
  • Equipo D: sh0-api + sh0-docker + sh0-git (13 correcciones, ~15 nuevas pruebas)

La restriccion clave: cero superposicion de archivos entre equipos. Esto elimino conflictos de merge y permitio que los cuatro flujos se ejecutaran simultaneamente. Despues de que todos los equipos completaron, un unico pase de integracion verifico que los cambios combinados compilaban y las 206 pruebas (172 existentes + 34 nuevas) pasaban.

El mismo patron se aplico a las Rondas 3 y 4: agrupar correcciones por limite de crate, paralelizar, verificar.


Patrones que vimos repetidamente

A traves de las cuatro rondas de auditoria, ciertas categorias de vulnerabilidad aparecieron una y otra vez:

1. Validacion de entrada en el limite. Cada lugar donde la entrada del usuario entra al sistema -- cuerpos de peticion HTTP, parametros de consulta, payloads de webhook, archivos YAML, expresiones cron, cadenas de comando -- necesita validacion. Cuanto mas lejos viaja la entrada sin validacion, mas dificil la correccion.

2. SSRF donde sea que se acepten URLs. Si tu sistema hace peticiones HTTP a URLs proporcionadas por el usuario -- despacho de webhooks, monitorizacion de uptime, configuracion de proxy -- necesitas filtrado de IP privada. Los endpoints de metadatos cloud en 169.254.169.254 son el objetivo mas comun, pero los servicios internos en 10.x.x.x y 172.16.x.x son igualmente peligrosos.

3. Canales laterales de timing. Cualquier comparacion de secretos -- claves API, contrasenas, codigos TOTP -- debe ser en tiempo constante. La comparacion estandar de cadenas filtra informacion a traves del timing de respuesta.

4. Autorizacion faltante despues de autenticacion. Verificar que un usuario esta logueado no es lo mismo que verificar que tiene permiso para realizar una accion. Varios endpoints verificaban autenticacion (el usuario tiene un token valido) pero no autorizacion (el usuario tiene el rol correcto para esta operacion en este recurso).

5. Unwraps que causan panics. Cada .unwrap() en codigo de handler de peticion es un vector potencial de denegacion de servicio. Si el unwrap se dispara con una entrada malformada, el handler entra en panic, la tarea Tokio termina, y el cliente obtiene un error 500 opaco. Reemplaza cada .unwrap() en codigo de manejo de peticiones con propagacion de errores adecuada.


Los numeros despues de la remediacion

RondaTotal encontradoCriticos corregidosAltos corregidosMedios corregidosPruebas anadidas
2889/912/124/4534
345Todos CRITICOSTodos ALTOSMayoria MEDIOSActualizadas existentes
4517/712/1212/1810

Despues de las tres sesiones de remediacion: - cargo test: 312 pruebas pasando - cargo clippy -- -D warnings: cero advertencias - cargo build --release: compilacion limpia - Build del Dashboard: limpio


Por que auditarte a ti mismo primero

El consejo estandar es contratar a un pentester externo. Es buen consejo -- deberias hacerlo. Pero una auditoria externa sobre codigo que nunca has revisado tu mismo es un desperdicio de dinero. Pasaran la mitad de su tiempo encontrando problemas que podrias haber encontrado con una lectura cuidadosa, y pagaras su tarifa por hora por ello.

Auditarte primero. Se sistematico. Ve fase por fase, archivo por archivo. Anota cada hallazgo con su severidad, ubicacion y correccion propuesta. Luego corrige los hallazgos criticos y altos. Despues trae al auditor externo, que ahora puede enfocarse en los problemas sutiles: fallas de logica de negocio, condiciones de carrera, patrones de mal uso criptografico -- las cosas que requieren experiencia profunda para encontrar.

Encontramos 88 problemas solo en la Ronda 2. Si un auditor externo los hubiera encontrado, habria sido un compromiso costoso. En cambio, los encontramos nosotros mismos, los corregimos en una sola sesion y anadimos 34 pruebas de regresion para asegurar que nunca vuelvan.


Lo que queda

No todos los hallazgos se corrigieron inmediatamente. Los 45 medios y 22 bajos de la Ronda 2 incluyen items como:

  • Reduccion de expiracion JWT con tokens de actualizacion (implementado despues en la migracion de cookies)
  • Requisitos de complejidad de contrasena
  • Bloqueo de cuenta despues de intentos fallidos
  • Expiracion y alcance de claves API
  • Aplicacion de timeout de build
  • Entornos de preview de despliegue
  • Logging de auditoria para todos los eventos de seguridad

Estas son mejoras reales, no teoricas. Estan priorizadas en el backlog, y estamos trabajando en ellas. Pero los hallazgos criticos y altos -- los que podrian llevar a ejecucion de codigo, exposicion de datos o escalada de privilegios -- estan todos corregidos. Ese es el punto de la clasificacion por severidad: corregir lo que mas importa, primero.


Conclusiones clave

  1. Auditar en rondas, no todo de una vez. Dividir la auditoria en fases hace la tarea manejable y asegura cobertura. No encontraras todo en un solo pase.
  2. Paralelizar correcciones por limite de archivos. Agrupar hallazgos por crate o modulo, asignar conjuntos de archivos sin superposicion y ejecutar simultaneamente. Esto convirtio una remediacion de varios dias en una sola sesion.
  3. Cada .unwrap() en codigo de handler es un bug. No eventualmente. No teoricamente. Es un vector de denegacion de servicio hoy.
  4. SSRF esta en todas partes. Cualquier funcionalidad que hace peticiones HTTP a URLs proporcionadas por el usuario necesita filtrado de IP privada. Esto incluye webhooks, monitorizacion, configuracion de proxy y health checks.
  5. El numero no importa. La severidad si. 88 hallazgos suena alarmante. Pero los 9 hallazgos criticos son lo que importa. Corrige esos, y la plataforma pasa de "explotable" a "endurecida". Corrige los 79 restantes a un ritmo medido.

Siguiente en la serie: Migrando de tokens en localStorage a cookies HTTP-Only -- como reemplazamos el anti-patron de autenticacion mas comun en aplicaciones de pagina unica.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles