Informe de seguridad del BSI en Hetzner: cómo protegí mi servidor Docker
Hetzner me reenvió un informe de seguridad del BSI por un servidor expuesto. Cómo audité los puertos Docker abiertos, activé ufw sin romper mi reverse proxy y endurecí SSH.
16 min de lectura

Hace unos días recibí un correo de Hetzner que empezaba así:
Hemos recibido una notificación de la Oficina Federal Alemana para la Seguridad de la Información (BSI) sobre (la dirección IP de) un servidor que tienes con nosotros.
No, no me habían hackeado. No, el BSI no me acusaba de nada. Esto es rutina: el BSI ejecuta escáneres de seguridad sobre el espacio de IPs alemán, encuentra puertos abiertos y servicios expuestos, y notifica al proveedor de hosting. El proveedor reenvía el informe al cliente. El correo incluso dice que no hace falta responder, y que el problema es “normalmente bastante fácil de solucionar”.
Había un detalle: el informe original (con el puerto exacto y el CVE) era un adjunto que tenía que rescatar del hilo de correos. No quería esperar, así que audité el servidor yo mismo. Tardé unos 20 minutos, y resultó que el escáner del BSI había elegido un buen objetivo: la máquina estaba completamente abierta.
Aquí va la historia completa: cómo audité el servidor, el conjunto de reglas de ufw que lo arregló, la trampa que rompió brevemente mi reverse proxy (y la única regla que lo solucionó), y los pasos de defensa en profundidad que tomé después. Si tienes un VPS con Docker, haz esta auditoría hoy.
Qué es un informe del BSI (y qué no es)
El BSI es la Oficina Federal Alemana para la Seguridad de la Información. Su equipo de respuesta a emergencias informáticas, CERT-Bund, escanea los rangos de IP alemanes en busca de vulnerabilidades conocidas y servicios expuestos: bases de datos abiertas, paneles de administración sin autenticación, software desactualizado con CVEs públicos. Cuando encuentran algo en una IP de un proveedor alemán, envían un informe y el proveedor te lo reenvía.
Los datos importantes:
- No es una acusación, una amenaza ni una orden de retirada. No hubo abuso de por medio.
- No necesitas responder al BSI ni a tu proveedor.
- El informe nombra el servicio exacto, el puerto y/o el CVE. Arréglalo y listo.
- El mismo escaneo se ejecuta contra cada IP alemana. El hallazgo significa que alguien verificó que tu exposición era alcanzable.
Mi informe se reducía a un tema: servicios escuchando en 0.0.0.0 sin ningún firewall.
Paso 1: Audita qué está escuchando realmente
Tres comandos te dan el 95% del panorama.
1. Puertos en escucha y sus procesos:
sudo ss -tulpn2. Contenedores Docker y sus puertos publicados:
docker ps --format 'table {{.Names}}\t{{.Ports}}\t{{.Image}}'3. Estado del firewall:
sudo ufw status verboseEsto es lo que revelaron los dos primeros en mi servidor:
| Puerto | Servicio | Riesgo |
|---|---|---|
| 5432 | PostgreSQL (contenedor pgvector) | 🔴 Base de datos expuesta |
| 5532 | PostgreSQL (segunda instancia) | 🔴 Base de datos expuesta |
| 8055 | Directus CMS | 🔴 Panel de administración expuesto |
| 9999, 18888 | App Hindsight | 🔴 App expuesta |
| 6806 | Notas Siyuan | 🔴 App expuesta |
| 3552 | Arcane | 🟡 App expuesta |
| 22 | SSH | 🟡 Login de root + autenticación por contraseña |
| 80, 443 | Reverse proxy Caddy | 🟢 Intencionado |
Y el tercer comando dijo Status: inactive. Nada filtraba nada. Cada puerto Docker publicado era alcanzable desde internet, y SSH aceptaba logins de root con contraseña.
¿Cómo pasa esto? Es el patrón clásico de Docker: publicas un puerto durante la configuración para poder alcanzar el servicio, luego pones un reverse proxy (Caddy, Traefik, nginx) delante, y nunca eliminas el bloque ports:. El proxy funciona, la app funciona, y nadie nota que los puertos en crudo siguen vinculados a 0.0.0.0 — directamente alcanzables desde internet, evitando tu proxy, su rate limiting y cualquier autenticación que hayas añadido ahí.
El BSI lo nota. También lo nota cada escáner de botnet que recorre el espacio de IPs alemán. A los pocos minutos de levantar el firewall, vi cómo las sondas hacia exactamente el puerto señalado eran descartadas.
Paso 2: Activa el firewall
La solución más rápida, y la que cerró todos los puertos de una vez, es ufw. Este es el conjunto completo de reglas que funciona para un servidor Docker con un reverse proxy público:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow from 172.16.0.0/12 comment 'Docker bridge networks -> host services'
sudo ufw allow in on tailscale0 # only if you use Tailscale
sudo ufw --force enableRegla por regla:
default deny incoming— descarta toda nueva conexión entrante que no esté explícitamente permitida. Las conexiones establecidas siguen vivas, así que tu sesión SSH actual sobrevive a la activación.22/tcp— SSH. Si solo accedes por SSH a través de Tailscale, omite esta regla y deja que la reglatailscale0lo cubra.80/tcpy443/tcp— tu reverse proxy. Es la única puerta que el tráfico público debería usar.from 172.16.0.0/12— no te saltes esta; es la regla que mantiene funcionando tu reverse proxy de Docker. Se explica en el Paso 3.in on tailscale0— si usas Tailscale, la mesh puede alcanzar todo (incluidos los puertos de administración) sin abrirlos a internet.--force— omite el aviso de “this may disrupt existing ssh connections”.
Luego verifica:
sudo ufw status verboseQuieres ver Default: deny (incoming), allow (outgoing), deny (routed) y tu lista de permisos debajo.
La política routed es tu aliada
Las versiones recientes de ufw añaden una tercera política por defecto: deny (routed). Se aplica al tráfico reenviado a través del host — que es exactamente cómo funcionan los puertos publicados de Docker. Así que una vez que ufw está activo, puertos publicados como 5432 se descartan desde internet aunque sigan en tus archivos compose. Si estás en un ufw más antiguo y los puertos de Docker aún se cuelan, la solución es la cadena iptables DOCKER-USER, explicada en cómo arreglar que Docker evite el firewall.
Paso 3: La trampa — ufw mató mi reverse proxy
Aquí está la parte que te ahorrará un ataque de pánico.
Mi configuración: una app de agente de IA corriendo en el host en el puerto 4111, con un contenedor Caddy haciendo reverse proxy de un subdominio hacia ella vía host.docker.internal:4111. Patrón estándar: el proxy vive en Docker, el backend vive en el host, y host.docker.internal (con host-gateway en extra_hosts) mapea a la IP del gateway del bridge del host.
Después de ufw enable, el subdominio devolvió 502 Bad Gateway. El contenedor de Caddy ya no podía alcanzar el host. Mi primera idea fue que había configurado mal el firewall. No era eso.
La razón: default deny incoming no bloquea solo la interfaz expuesta a internet. Descarta cualquier nueva conexión entrante a un puerto del host, desde cualquier interfaz — incluidos tus bridges de Docker. El contenedor de Caddy conectando desde 172.20.0.6 al gateway del host 172.17.0.1:4111 es tráfico “entrante” en lo que respecta al host. Denegado. Resultado: Caddy recibe una conexión rechazada y devuelve 502.
La solución es la regla 172.16.0.0/12 del Paso 2:
sudo ufw allow from 172.16.0.0/12 comment 'Docker bridge networks -> host services'Docker crea sus redes bridge en 172.17.0.0/16 (docker0), 172.18.x, 172.19.x, 172.20.x, y así sucesivamente. 172.16.0.0/12 las cubre todas. Esto solo permite que tus propios contenedores alcancen los puertos del host — internet no puede falsificar una dirección de origen en tus bridges privados, así que nada se reabre hacia afuera.
Añade la regla, revisa de nuevo ufw status verbose, y tu proxy vuelve. En mi caso el sitio devolvió 200 inmediatamente después.
Paso 4: Verifica — y observa cómo los bots chocan contra el muro
Tres comprobaciones.
1. Tu sitio público sigue funcionando (ejecuta desde el propio servidor para que salga por la interfaz pública):
curl -sk -o /dev/null -w 'HTTP %{http_code}\n' https://your-domain.comEspera un 200.
2. El estado del firewall:
sudo ufw status verbose3. La parte satisfactoria — las sondas bloqueadas:
sudo journalctl -k --no-pager | grep 'UFW BLOCK' | tail -10A los pocos minutos de activar ufw, el mismo tipo de escáneres que provocaron mi informe chocó contra el muro:
kernel: [UFW BLOCK] IN=eth0 ... SRC=186.236.254.56 ... DPT=5432 SYN
kernel: [UFW BLOCK] IN=eth0 ... SRC=178.128.214.243 ... DPT=23 SYN
kernel: [UFW BLOCK] IN=eth0 ... SRC=91.230.168.148 ... DPT=50050 SYNPuerto 5432 — exactamente lo que el informe del BSI señaló — además de Telnet (23) y puertos aleatorios. Todo descartado. Ese es el punto: la exposición que provocó mi informe ahora es invisible desde internet.
Paso 5: Defensa en profundidad (haz esto también)
El firewall cierra las puertas, pero está a un sudo ufw disable de abrirlas de nuevo. Haz que la exposición sea estructural:
1. Despublica puertos en tus archivos compose. Si solo otros contenedores necesitan alcanzar un servicio, borra el bloque ports: por completo — los contenedores se comunican por el bridge de Docker usando el nombre del servicio. Si necesitas acceso local en el host (por ejemplo, un psql local), vincúlalo a loopback en vez de 0.0.0.0:
ports:
- "127.0.0.1:5432:5432"2. Endurece SSH. Mi servidor tenía PermitRootLogin yes y autenticación por contraseña activada. Como ya tienes claves (confírmalo con ls ~/.ssh/id_ed25519.pub), añade una configuración drop-in:
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
EOFPrueba antes de reiniciar, y mantén una segunda sesión SSH abierta:
sudo sshd -t && sudo systemctl restart sshUna guía completa de autenticación por claves, fail2ban y el resto está en la guía de endurecimiento del servidor SSH.
3. Pon autenticación a todo lo que no la tenga. Los paneles de administración y herramientas de desarrollo sin login propio deberían estar detrás de basic auth en el proxy — la misma idea que en Traefik basic authentication.
4. Añade una capa de comportamiento. Un firewall es estático; herramientas como CrowdSec vigilan los logs y bloquean IPs infractoras dinámicamente. Vale la pena tenerla encima.
Checklist
Aquí está la secuencia exacta que ejecutaría en un servidor nuevo, previamente expuesto:
- Audita con
sudo ss -tulpn,docker psysudo ufw status verbose - Anota qué puertos deberían ser públicos (normalmente solo 80 y 443)
- Añade las reglas de ufw del Paso 2 antes de activar — incluida la regla del bridge de Docker
172.16.0.0/12 - Activa con
sudo ufw –force enable - Verifica que tu sitio devuelve 200 y revisa
journalctl -k | grep ‘UFW BLOCK’ - Elimina o vincula a loopback los puertos publicados en cada archivo compose
- Endurece SSH (configuración drop-in,
sudo sshd -t, reinicio, mantén una segunda sesión) - Comprueba desde una red externa que solo 22, 80 y 443 responden
FAQ
¿Tengo que responder al BSI o a mi proveedor de hosting?
No. La notificación es informativa. El correo de reenvío de Hetzner lo dice explícitamente: “No necesitas enviarnos una respuesta, ni a nosotros ni al BSI.” Si quieres confirmar el hallazgo exacto, busca el número de ticket (CB-Report#...) en el informe original y úsalo si alguna vez contactas con certbund@bsi.bund.de. No respondas a la dirección del remitente del correo del informe — no está monitorizada.
¿ufw realmente bloquea los puertos publicados de Docker?
En las versiones modernas, sí. La política por defecto deny (routed) de ufw se aplica al tráfico reenviado, que es cómo los puertos publicados de Docker llegan a los contenedores, así que se descartan desde internet. En versiones antiguas de ufw, las reglas iptables propias de Docker se evaluaban antes que el firewall y podían evitarlo por completo — la solución con la cadena DOCKER-USER está documentada en cómo arreglar que Docker evite el firewall. En cualquier caso, el enfoque de doble seguridad es despublicar los puertos en compose.
¿Por qué mi reverse proxy devolvió 502 después de activar ufw?
Porque default deny incoming también bloquea el tráfico de contenedor a host a través del bridge de Docker. Si tu contenedor proxy alcanza un servicio del host vía host.docker.internal:PORT, la conexión llega a la cadena INPUT del host como tráfico entrante y se descarta. Permite el rango del bridge de Docker con sudo ufw allow from 172.16.0.0/12 y el proxy vuelve a conectar.
¿Debería arreglar los puertos aunque el firewall los bloquee?
Sí. El firewall está a un comando de ser desactivado (accidentalmente o durante una depuración), y entonces cada puerto publicado vuelve a ser público. Eliminar o vincular a loopback los ports: en compose hace que la exposición sea estructural en vez de incidental. También reduce la superficie de ataque desde la perspectiva del contenedor — menos sockets alcanzables, menos CVEs que importan.
Conclusión
La notificación del BSI no era una amenaza — era un escaneo de vulnerabilidades gratuito, cortesía del gobierno alemán. Los servicios expuestos eran culpa mía: puertos Docker publicados olvidados tras la configuración, ningún firewall en el host y valores SSH débiles por defecto. Veinte minutos de auditoría más un conjunto de reglas de firewall lo cerraron todo, y la única sorpresa real (la trampa del bridge de Docker vs. ufw) ahora es una regla de una línea en mi configuración estándar de servidores.
Si estás autoalojando en un VPS, dedica esos 20 minutos hoy. Los escáneres lo harán sin duda.


