Redes en Docker: la única interpretación errónea de NAT/firewall que expone todo

¿Te fue útil?

Bloqueas un host con una política de firewall ordenada. Ejecutas un contenedor con “solo un puerto de prueba”. Minutos más tarde, un escáner en otra zona horaria ya está hablando con él. No “abriste” nada—al menos no en tu cabeza.

Esta es la trampa de las redes Docker: una interpretación errónea de cómo interactúan NAT y las cadenas del firewall, y tu modelo mental deja de coincidir con la realidad del kernel. El kernel siempre gana. Asegurémonos de que gane a tu favor.

La interpretación errónea: “Mi firewall decide qué es alcanzable”

El malentendido más caro en redes Docker es pensar que las reglas de tu firewall del host se evalúan “normalmente” para los puertos publicados por contenedores.

En un host Linux que utiliza la red por defecto de puente de Docker, Docker instala reglas de iptables que reescriben el tráfico (DNAT) y lo encamnan hacia los contenedores. Si tu política de firewall asumía un simple “INPUT decide, luego ACCEPT/DROP”, puedes estar equivocado de dos maneras a la vez:

  • Puede que estés filtrando la cadena equivocada. Los puertos publicados por contenedores suelen ser tráfico reencaminado, no entrega local. Eso significa que la decisión suele estar en FORWARD, no en INPUT.
  • Puede que estés filtrando demasiado tarde. Las reescrituras NAT ocurren antes del filtrado de formas que cambian lo que ven tus reglas. Piensas que estás bloqueando el puerto 8080 en el host, pero tras el DNAT es “puerto 80 a 172.17.0.2”, y tu regla ya no coincide.

El trabajo de Docker es hacer que los contenedores sean alcanzables. Tu trabajo es decidir desde dónde. Si no afirmas explícitamente ese límite en el lugar correcto, Docker hará felizmente la parte de alcanzar para todo Internet.

Verdad seca: la postura por defecto de “publicar un puerto” es “hacerlo alcanzable”. La postura por defecto de “ejecutar en una VM en la nube” es “asumir que alguien te escanea”. Combina ambas y obtienes un ticket que nadie disfruta.

El verdadero recorrido del paquete: conntrack, NAT, filter y dónde se engancha Docker

Un modelo mental mínimo que no te miente

Cuando un paquete llega a un host Linux, el kernel no le pregunta a tu firewall cortésmente si puede existir. Clasifica el paquete, consulta conntrack, lo pasa por los hooks de NAT, luego por los hooks de filtrado, y luego lo enruta. El orden importa.

Para una conexión TCP entrante típica a un puerto publicado por Docker en el host (por ejemplo, 203.0.113.10:443), las paradas importantes son:

  1. PREROUTING (nat): La regla DNAT de Docker puede reescribir el destino desde la IP:puerto del host a la IP:puerto del contenedor.
  2. Decisión de enrutamiento: Tras el DNAT, el kernel puede decidir que este tráfico no está destinado al propio host sino que debe reenviarse a una interfaz de puente (docker0).
  3. FORWARD (filter): Aquí es donde muchos firewalls del host olvidan mirar. Docker añade reglas de accept para flujos establecidos y para puertos publicados.
  4. POSTROUTING (nat): Para el tráfico saliente desde contenedores, Docker típicamente aplica SNAT/MASQUERADE para que las respuestas parezcan venir del host.

Las cadenas importantes de Docker (backend iptables)

En sistemas que usan el backend iptables, Docker suele crear y usar estas cadenas:

  • DOCKER (en nat y filter): contiene reglas DNAT y algunas reglas de filtrado para redes de contenedores.
  • DOCKER-USER (en filter): tu punto de inserción soportado. Docker salta aquí temprano para que puedas aplicar tu política.
  • DOCKER-ISOLATION-STAGE-1/2: usado para aislar redes Docker unas de otras.

Por qué las “reglas” de UFW/firewalld pueden parecer correctas y aun así fallar

Muchos gestores de firewall a nivel de host son envoltorios. Generan reglas iptables/nftables en ciertas cadenas con ciertas prioridades. Si se centran en INPUT pero tu tráfico de contenedor se está reenviando, tu “deny” nunca tiene voto.

Además, Docker puede insertar reglas antes que tus reglas gestionadas por la distro, dependiendo de cómo esté construido tu firewall. El paquete coincidirá con la primera regla aceptable y jamás llegará a tu DROP cuidadosamente curado.

Una idea parafraseada de Werner Vogels (CTO de Amazon): “Todo falla todo el tiempo; diseña y opera como si la falla fuera normal.” Aplica eso también a la seguridad: asume que la mala configuración es normal y construye salvaguardas.

Hechos interesantes y contexto histórico

  • Linux netfilter es anterior a Docker por una década. iptables se hizo popular a principios de los 2000, construido sobre hooks de netfilter en el kernel.
  • Conntrack es estado, no magia. El seguimiento de conexiones permite reglas “ESTABLISHED,RELATED” que hacen los firewalls utilizables, pero también significa que un paquete permitido puede crear un flujo permitido de larga duración.
  • El bridge por defecto de Docker es un bridge clásico de Linux. No es un “switch Docker” especial; es el mismo primitivo usado por VMs y namespaces de red durante años.
  • “Publicar” significa “ligar a todas las interfaces” salvo que se indique lo contrario. -p 8080:80 normalmente se liga a 0.0.0.0 (y a menudo a ::) a menos que especifiques una IP.
  • Docker introdujo DOCKER-USER como concesión a la realidad. La gente necesitaba un punto estable para aplicar política que sobreviva a reinicios de Docker y que no sea reescrito.
  • nftables no reemplazó instantáneamente a iptables en la práctica. Muchas distribuciones migraron a nftables internamente, pero las herramientas, expectativas e integración con Docker tardaron años en ponerse al día.
  • Hairpin NAT es anterior a tu incidente actual. El problema de “el host se habla a sí mismo vía su IP pública” existe en muchos dispositivos NAT; Docker puede desencadenar rarezas similares en una sola máquina.
  • El networking ingress de Swarm usa su propia tubería. El routing mesh puede publicar puertos en todos los nodos, lo que sorprende a equipos que esperan “solo el nodo con la tarea está expuesto”.
  • Los security groups en la nube no son sustituto de la política del host. Son una capa, no una garantía—reglas mal aplicadas o cambios posteriores pueden seguir exponerte.

Cómo ocurre la “exposición” realmente (cuatro modos comunes)

Modo 1: Publicaste un puerto y olvidaste que se enlaza al mundo

docker run -p 8080:80 … es conveniente. También es una exposición explícita. Si querías “solo localhost”, necesitas decirlo: -p 127.0.0.1:8080:80.

Este es el bug de “no pensé que sería público”. Docker pensó que sí. Docker tenía razón.

Modo 2: Tu firewall filtra INPUT, pero el tráfico de Docker pasa por FORWARD

Si el paquete es DNAT’d a una IP de contenedor, ya no está destinado al host. Eso lo empuja a la lógica de reenvío. Si tu postura de seguridad ignora FORWARD, has dejado una puerta lateral abierta—de par en par.

Modo 3: Confías en los valores por defecto de UFW/firewalld que no contemplan las cadenas de Docker

Algunos gestores de firewall establecen políticas por defecto de forward en ACCEPT, o no gestionan las cadenas de Docker en absoluto. Puedes acabar con un firewall que parece restrictivo para el host, mientras los contenedores van por un carril rápido separado.

Modo 4: Optimizaste por rendimiento y accidentalmente eliminaste un punto de estrangulamiento

Deshabilitar conntrack, cambiar bridge-nf-call-iptables, cambiar backends de iptables o activar funciones de “ruta rápida” puede cambiar qué reglas se ejecutan y cuándo. Aquí es donde “funcionó en staging” muere.

Broma #1: NAT es como un organigrama corporativo—todo pasa por ahí y nadie admite que lo posee.

Tareas prácticas: comandos, salidas y la decisión que tomas

Estos no son “ejecuta esto porque lo dijo un blog”. Cada uno te dice algo concreto. Cada uno tiene una decisión adjunta.

Tarea 1: Lista los puertos publicados y sus direcciones de enlace

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES          IMAGE             PORTS
web            nginx:1.25        0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
metrics        prom/prometheus   127.0.0.1:9090->9090/tcp
db             postgres:16       5432/tcp

Qué significa: web es alcanzable en todas las interfaces IPv4 e IPv6. metrics es solo localhost. db no está publicado.

Decisión: Si no debería ser alcanzable desde Internet, para y vuelve a ejecutar con una IP de enlace explícita o elimina la publicación. No “lo arregles luego en el firewall” a menos que también controles DOCKER-USER.

Tarea 2: Confirma qué está realmente escuchando en el host

cr0x@server:~$ sudo ss -lntp | sed -n '1,8p'
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      4096   0.0.0.0:8080       0.0.0.0:*       users:(("docker-proxy",pid=2211,fd=4))
LISTEN 0      4096   127.0.0.1:9090     0.0.0.0:*       users:(("docker-proxy",pid=2332,fd=4))
LISTEN 0      4096   0.0.0.0:22         0.0.0.0:*       users:(("sshd",pid=1023,fd=3))

Qué significa: Docker está exponiendo 8080 en todas las interfaces. Incluso si existen reglas iptables, un socket en escucha es el primer paso de la alcanzabilidad.

Decisión: Si ves 0.0.0.0 o :: y no lo pretendías, primero corrige las flags de publicación o la definición del servicio.

Tarea 3: Inspecciona el uso de docker-proxy (y no asumas que desapareció)

cr0x@server:~$ ps -ef | grep -E 'docker-proxy|dockerd' | head
root      1190     1  0 08:11 ?        00:00:12 /usr/bin/dockerd -H fd://
root      2211  1190  0 09:02 ?        00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
root      2332  1190  0 09:03 ?        00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 -host-port 9090 -container-ip 172.17.0.3 -container-port 9090

Qué significa: Algunas configuraciones aún usan userland proxying para puertos publicados. Eso puede cambiar cómo los paquetes atraviesan el firewall del host y afectar el logging.

Decisión: Si estás solucionando “por qué mi regla INPUT no coincidió”, anota si el tráfico se está proxyando localmente o se reenvía al contenedor.

Tarea 4: Ver las reglas iptables de Docker en la tabla nat (donde ocurre el DNAT)

cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,60p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Qué significa: Cualquier paquete destinado a direcciones locales en el puerto 8080 puede ser DNAT’d al contenedor. Eso incluye IPs públicas en el host.

Decisión: Si necesitas restringir fuentes, hazlo en DOCKER-USER (filter) o ajusta la publicación/enlace; no pelees contra DNAT con reglas INPUT tardías.

Tarea 5: Comprueba el orden de la tabla filter, especialmente DOCKER-USER

cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT

Qué significa: Docker está saltando a DOCKER-USER temprano. Eso es bueno: tienes un punto de estrangulamiento. La política por defecto de FORWARD es DROP aquí, lo cual también es bueno.

Decisión: Pon tus listas de permisos/denegaciones en DOCKER-USER. Si tu sistema no tiene ese salto, debes arreglar ese modelo de política inmediatamente.

Tarea 6: Inspecciona la cadena DOCKER-USER (tu punto de aplicación)

cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN

Qué significa: No se está aplicando ninguna política para el tráfico reenviado a contenedores. Todo pasa sin cambios.

Decisión: Añade reglas explícitas: denegar por defecto y permitir solo lo que deba ser alcanzable.

Tarea 7: Añadir una regla “denegar por defecto inbound a contenedores” de forma segura

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN

Qué significa: Las nuevas conexiones entrantes que lleguen por eth0 y se reenvíen a contenedores vía docker0 serán descartadas. Los flujos ya establecidos siguen permitidos mediante otras reglas.

Decisión: Este es tu freno de emergencia. Después de aplicarlo, permite selectivamente los puertos publicados requeridos desde las fuentes necesarias.

Tarea 8: Permitir un servicio publicado específico desde un rango de origen específico

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN

Qué significa: Estás permitiendo solo el rango de origen previsto para alcanzar ese puerto del contenedor, por delante del drop por defecto.

Decisión: Prefiere listas de permitidos por origen y destino cuando sea posible. Si no puedes, al menos restringe por interfaz y puerto.

Tarea 9: Verifica desde la perspectiva del host qué ruta e interfaz usa el reenvío

cr0x@server:~$ ip route get 172.17.0.2
172.17.0.2 dev docker0 src 172.17.0.1 uid 1000
    cache

Qué significa: El tráfico hacia la IP del contenedor va por docker0. Eso valida que tu interfaz coincide con las reglas del firewall.

Decisión: Si usas redes personalizadas (por ejemplo, br-*), actualiza las reglas para que coincidan con las interfaces de salida correctas.

Tarea 10: Comprueba los sysctls de bridge netfilter que cambian si el tráfico en puente pasa por iptables

cr0x@server:~$ sudo sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1

Qué significa: El tráfico en puente recorrerá las reglas de iptables. Si estos valores son 0, algunas expectativas de filtrado se rompen y puedes acabar con la confusión de “las reglas del firewall no funcionan”.

Decisión: Mantén estos valores habilitados a menos que comprendas profundamente las compensaciones y tengas un mecanismo alternativo de aplicación.

Tarea 11: Determina si iptables está usando el backend nft (importa para depurar)

cr0x@server:~$ sudo iptables -V
iptables v1.8.9 (nf_tables)

Qué significa: Los comandos iptables están manipulando nftables por debajo. El orden de las reglas y la coexistencia con reglas nft nativas pueden ser sorprendentes.

Decisión: Al depurar, usa también nft list ruleset, no solo la salida de iptables.

Tarea 12: Inspecciona el ruleset de nftables para la interacción con Docker

cr0x@server:~$ sudo nft list ruleset | sed -n '1,80p'
table inet filter {
  chain forward {
    type filter hook forward priority 0; policy drop;
    jump DOCKER-USER
    jump DOCKER-ISOLATION-STAGE-1
    ct state related,established accept
  }
  chain DOCKER-USER {
    iif "eth0" oif "docker0" ct state new drop
    return
  }
}
table ip nat {
  chain PREROUTING {
    type nat hook prerouting priority -100; policy accept;
    fib daddr type local jump DOCKER
  }
  chain DOCKER {
    tcp dport 8080 dnat to 172.17.0.2:80
  }
}

Qué significa: Puedes ver la misma estructura lógica en términos de nftables: NAT en PREROUTING, filtrado en forward y tu política DOCKER-USER.

Decisión: Si tu distro usa nftables de forma nativa, considera gestionar la política de Docker en nft directamente para coherencia—pero asegúrate de que Docker siga manteniendo sus cadenas estables.

Tarea 13: Confirma si un puerto es alcanzable desde un punto externo

cr0x@server:~$ nc -vz -w 2 203.0.113.10 8080
Connection to 203.0.113.10 8080 port [tcp/*] succeeded!

Qué significa: Es alcanzable. Ninguna cantidad de “pensé que el firewall…” cambia eso.

Decisión: Si esto no debería ser alcanzable, detén el tráfico en DOCKER-USER o elimina la publicación y vuelve a probar. Luego verifica IPv6 por separado.

Tarea 14: Comprueba explícitamente la exposición IPv6

cr0x@server:~$ sudo ss -lnt | grep ':8080 '
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
LISTEN 0 4096 [::]:8080    [::]:*

Qué significa: También estás escuchando en IPv6. Si tu security group o firewall solo consideró IPv4, podrías tener exposición IPv6 accidental.

Decisión: Asegura IPv6 de forma equivalente o desactívalo deliberada y cuidadosamente para el servicio. “No usamos IPv6” no es un control.

Tarea 15: Traza la travesía del paquete con contadores (¿tu regla siquiera coincidió?)

cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
 pkts bytes target  prot opt in  out     source          destination
   12   720 ACCEPT  tcp  --  *   *       198.51.100.0/24 172.17.0.2        tcp dpt:80
   55  3300 DROP    all  --  eth0 docker0 0.0.0.0/0       0.0.0.0/0        ctstate NEW
  890 53400 RETURN  all  --  *   *       0.0.0.0/0       0.0.0.0/0

Qué significa: Los contadores cuentan la historia: la regla de permitir coincidió con 12 paquetes; la regla de DROP está bloqueando activamente nuevos intentos.

Decisión: Si los contadores no se mueven, estás mirando la cadena equivocada o la interfaz equivocada. Deja de adivinar; sigue los contadores.

Tarea 16: Verifica las redes Docker y los nombres de interfaces bridge

cr0x@server:~$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
c2f6b2a1c3c1   bridge    bridge    local
a7d1f9e2a5b7   appnet    bridge    local
cr0x@server:~$ ip link show | grep -E 'docker0|br-'
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
6: br-a7d1f9e2a5b7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default

Qué significa: Las redes bridge definidas por el usuario crean interfaces br-*. Si solo escribiste reglas para docker0, otras redes pueden seguir expuestas.

Decisión: Actualiza la política del firewall para cubrir todas las interfaces bridge de Docker, no solo la por defecto.

Guion de diagnóstico rápido

Si sospechas que un puerto de contenedor está expuesto (o que un firewall “no funciona”), no tomes la ruta escénica. Haz esto en orden.

Primero: identifica la superficie de exposición

  1. Lista los puertos publicados (docker ps con Ports) y busca 0.0.0.0 / ::.
  2. Revisa los sockets en escucha (ss -lntp) para confirmar qué está ligado y por qué proceso.
  3. Prueba la alcanzabilidad desde fuera (o desde un host puente) con nc o curl.

Segundo: localiza el punto de decisión en netfilter

  1. Encuentra reglas DNAT en iptables -t nat -S (o en la tabla nat de nft).
  2. Comprueba el orden de la cadena FORWARD y confirma que se salta a DOCKER-USER temprano.
  3. Usa contadores (iptables -L -v) para ver qué reglas están coincidiendo realmente.

Tercero: arregla con el cambio menos ingenioso

  1. Prefiere restringir el bind (-p 127.0.0.1:…) o quitar -p por completo.
  2. Aplica una política base en DOCKER-USER: drop por defecto para nuevas conexiones entrantes a bridges Docker; allowlist lo que debe ser público.
  3. Vuelve a probar la alcanzabilidad externa y valida que los contadores se movieron como esperabas.

Broma #2: Si vas a “abrir un puerto por cinco minutos”, tus atacantes son puntuales.

Errores comunes: síntoma → causa raíz → solución

1) “UFW dice que está bloqueado, pero el contenedor sigue siendo alcanzable”

Síntoma: UFW niega 8080, pero nc desde Internet se conecta.

Causa raíz: El tráfico se está reenviando a un contenedor (FORWARD), mientras que las reglas de UFW cubren principalmente INPUT. Las reglas iptables de Docker permiten el reenvío.

Solución: Pon la política en DOCKER-USER. O bien drop por defecto nuevas conexiones entrantes a bridges Docker, o permite por lista blanca fuentes/puertos específicos.

2) “Bloqueé el puerto 8080 en INPUT, pero sigue funcionando”

Síntoma: Una regla de DROP en INPUT para tcp/8080 no hace nada.

Causa raíz: El DNAT en PREROUTING cambia el destino a IP:puerto de contenedor; el paquete ya no coincide con INPUT para entrega local.

Solución: Filtra en FORWARD (idealmente en DOCKER-USER). O no publiques en 0.0.0.0 en primer lugar.

3) “Solo está expuesto en IPv6 y nadie lo notó”

Síntoma: IPv4 está bloqueado; un escáner IPv6 reporta un puerto abierto.

Causa raíz: Docker publicó en ::; el firewall IPv6 no es equivalente; el security group ignora IPv6.

Solución: Añade reglas de firewall IPv6; enlaza explícitamente a IPv4 localhost si es necesario; o desactiva IPv6 con cuidado y a conciencia.

4) “Después de habilitar nftables, la red de Docker se volvió rara”

Síntoma: Puertos publicados fallan intermitentemente, o las reglas no aparecen donde esperas.

Causa raíz: Gestión mixta: reglas nft nativas más traducción iptables-nft más cadenas generadas por Docker. La prioridad y el orden de hooks difieren de las suposiciones.

Solución: Estandariza: o gestiona vía iptables consistentemente (con consciencia del backend nft) o adopta una política coherente en nftables que respete las cadenas de Docker.

5) “Los contenedores en una red bridge personalizada están expuestos aunque docker0 esté bloqueado”

Síntoma: Las reglas que referencian docker0 funcionan, pero los contenedores en br-* son alcanzables.

Causa raíz: Las redes definidas por el usuario usan distintas interfaces bridge; tus reglas no las cubren.

Solución: Empareja en oifname "br-*" (nft) o añade reglas de interfaz para cada bridge, o agrúpalas usando ipsets/conjuntos nft.

6) “Swarm publicó un puerto en todas partes”

Síntoma: Un servicio publicado en un nodo aparece alcanzable en todos los nodos.

Causa raíz: El routing mesh de Swarm (ingress) publica el puerto a nivel de clúster; el tráfico se reenvía internamente a una tarea activa.

Solución: Usa publicación en modo host para exposición local al nodo, o aplica restricciones de borde con firewalls/load balancers, y trata el ingress de Swarm como un plano de exposición compartido.

Tres microhistorias corporativas desde el campo

Microhistoria 1: El incidente causado por una suposición equivocada

Una empresa SaaS mediana migró un servicio legado a contenedores en un par de VMs en la nube. El equipo tenía la costumbre bien ganada: bloquear todo en el firewall del host y luego abrir agujeros solo para el reverse proxy y SSH desde la VPN.

Durante la migración, un ingeniero publicó un puerto para una UI de administración interna: -p 8443:8443. La suposición era razonable y de vieja escuela: “El firewall del host lo bloquea a menos que lo abramos”. No lo abrieron.

Dos días después, el equipo de seguridad detectó tráfico saliente a un rango de IPs desconocido. La UI de administración no fue “hackeada” al estilo Hollywood; simplemente era alcanzable. La UI tenía autenticación básica, pero también un endpoint que lanzaba trabajos de fondo costosos. Un desconocido en Internet lo encontró y lo trató como un calefactor crypto gratis.

La discusión post-incidente fue predecible. El equipo de la app culpó al firewall. Infra culpó al equipo de la app por publicar un puerto. Ambos tenían media razón, y así se generan incidentes repetidos.

La corrección real fue aburrida: aplicar un drop por defecto explícito para conexiones NEW entrantes a las interfaces bridge de Docker en DOCKER-USER, y requerir que los servicios se enlacen a 127.0.0.1 salvo que exista un ticket que pruebe la necesidad de acceso externo. La siguiente UI de administración se publicó a localhost y luego se enroutó vía reverse proxy con autentificación y logs de auditoría adecuados.

Microhistoria 2: La optimización que salió mal

Una plataforma cercana a trading buscaba latencia. Alguien notó el overhead en el procesamiento de paquetes y propuso “simplificar el firewall” confiando más en security groups upstream y menos en las reglas del host. En la misma ventana de cambios, voltearon algunos sysctls del kernel y limpiaron lo que consideraron cadenas iptables “redundantes”.

El rendimiento mejoró—lo suficiente como para que los gráficos se vieran bien en una reunión. Luego apareció un problema aparentemente no relacionado: un servicio en contenedor era alcanzable desde un segmento de red que no debía comunicarse con él. No era Internet pública, sino una subred interna amplia con demasiadas laptops y demasiada curiosidad.

La causa raíz no fue un único cambio; fue la combinación. Eliminar el filtrado de forward a nivel de host y cambiar el comportamiento de bridge netfilter hizo que algunos caminos de tráfico dejaran de pasar por los puntos de aplicación previstos. El security group upstream aún “se veía bien”, pero dentro del VPC el radio de impacto se amplió.

No deshicieron todo el trabajo de rendimiento. Reintrodujeron la aplicación en DOCKER-USER con un conjunto de reglas estrecho y mínimos matches, y midieron honestamente el impacto en latencia. Fue pequeño. La reducción de riesgo no lo fue.

Microhistoria 3: La práctica aburrida que salvó el día

Una compañía relacionada con la salud (mucho cumplimiento, muchas auditorías, muchas personas a las que les gustan los checkboxes) tenía una regla: cualquier puerto de contenedor publicado a algo que no sea localhost debe estar justificado, documentado y probado desde un punto externo como parte del cambio.

No era glamuroso. Los ingenieros refunfuñaban. Pero la pipeline incluía una etapa simple: desplegar en un host canario, ejecutar un pequeño conjunto de comprobaciones de conectividad externa y fallar el cambio si cualquier puerto que “debería ser privado” era alcanzable.

Un viernes, un responsable de servicio intentó “temporalmente” publicar un puerto de depuración en todas las interfaces para que un proveedor pudiera probar algo. La solicitud de cambio lo mencionaba, pero la evaluación de riesgos fue vaga. La pipeline lo detectó porque el puerto era alcanzable desde una red no aprobada en el entorno de pruebas.

La solución no fue heroica. Fue un pequeño cambio de configuración: publicar a 127.0.0.1 y acceder mediante un túnel SSH de corta duración desde un bastión controlado. El proveedor aún pudo probar. El puerto nunca se convirtió en un endpoint sorpresa en Internet. Todos se fueron a casa a tiempo, que es el verdadero KPI.

Listas de verificación / plan paso a paso

Lista básica de endurecimiento (host Docker único)

  1. Inventario de exposición: lista puertos publicados (docker ps) y sockets en escucha (ss -lntp).
  2. Decide la intención por puerto: público, solo VPN, interno o localhost-only.
  3. Haz explícito el binding: usa -p 127.0.0.1:HOST:CONTAINER para localhost-only; especifica una IP de interfaz interna si es necesario.
  4. Aplica política por defecto en DOCKER-USER: drop para nuevas conexiones entrantes reenviadas por defecto.
  5. Lista blanca intencionalmente: añade aceptaciones estrechas antes del drop—por rango de origen, IP/puerto destino del contenedor e interfaz cuando sea posible.
  6. Valida IPv6: comprueba listeners y paridad del firewall; no asumas que está desactivado.
  7. Persiste las reglas: asegura que tus reglas DOCKER-USER sobrevivan reinicios (específico del sistema: iptables-persistent, configuración nftables, etc.).
  8. Vuelve a probar desde fuera: verifica que la alcanzabilidad coincide con la intención.

Paso a paso: convertir una “publicación pública” en “privada tras reverse proxy”

  1. Cambia la publicación de pública a localhost:
    • De -p 8080:80 a -p 127.0.0.1:8080:80.
  2. Frénalo con un reverse proxy en el host o en un contenedor edge dedicado que sea el único puerto orientado a Internet.
  3. Aplica el drop base en DOCKER-USER para nuevas conexiones entrantes a interfaces bridge, de modo que “alguien republicó un puerto” no sea exposición instantánea.
  4. Añade autenticación, límites de tasa y logs en la capa del reverse proxy; los servicios en contenedores no deben reinventar la seguridad perimetral individualmente.
  5. Ejecuta validación externa con nc/curl desde una red no aprobada y confirma que falla.

Paso a paso: contención de emergencia cuando sospechas exposición

  1. Aplica un drop de emergencia en DOCKER-USER para nuevas conexiones entrantes a bridges docker (con alcance por interfaz) para detener la hemorragia.
  2. Confirma que la alcanzabilidad externa se detiene mediante una prueba desde fuera.
  3. Identifica los puertos publicados y elimínalos o restrínelos a nivel Docker run/compose.
  4. Reemplaza el drop de emergencia con reglas allowlist para que los servicios requeridos sigan siendo alcanzables.
  5. Audita la exposición IPv6 y aplica la contención correspondiente allí también.

Preguntas frecuentes

1) ¿Es “EXPOSE” en un Dockerfile lo mismo que publicar un puerto?

No. EXPOSE es metadata. La publicación ocurre con -p/--publish o en compose ports:. Solo publicar crea alcanzabilidad desde el host.

2) ¿Por qué mi cadena INPUT no ve el tráfico a puertos publicados de contenedores?

Porque tras el DNAT, el tráfico se enruta a una IP de contenedor y se convierte en tráfico reenviado. Eso se filtra típicamente en FORWARD, no en INPUT.

3) ¿Dónde debería poner mi política a nivel de host para el tráfico Docker?

En DOCKER-USER. Está diseñado como punto de inserción estable que Docker no reescribirá al reiniciarse.

4) Si me enlazo a 127.0.0.1, ¿estoy seguro?

Estás más seguro. El binding a localhost impide el acceso a la red externa a nivel de socket. Aun así, considera amenazas internas, compromisos locales y si otro proceso podría proxyarlo hacia afuera.

5) ¿Docker siempre usa docker-proxy para puertos publicados?

No. El comportamiento varía según la versión de Docker, las características del kernel y la configuración. No asumas un único camino de paquetes—confírmalo con ss y listados de procesos.

6) ¿Cómo cambia la historia IPv6?

Añade un segundo plano de exposición. Puedes tener “IPv4 seguro” y “IPv6 abierto” al mismo tiempo. Valida listeners y reglas de firewall para ambos.

7) Estoy usando nftables. ¿Debería dejar de usar comandos iptables?

Si tu sistema ejecuta iptables con backend nft, los comandos iptables siguen funcionando pero pueden ocultar el orden final de reglas. Para depuración profunda, inspecciona nft list ruleset.

8) ¿El Docker sin root evita estos fallos de firewall?

El modo rootless cambia la red y la mecánica de publicación de puertos, y puede reducir el radio de acción de privilegios a nivel de daemon. No elimina la necesidad de pensar claramente sobre qué es alcanzable y desde dónde.

9) ¿Docker Compose cambia algo de esto?

No hay un cambio fundamental. Compose es una interfaz más cómoda para los mismos primitivos. Si compose indica ports: - "8080:80", lo estás publicando al mundo a menos que especifiques una IP.

10) Solo confío en security groups de la nube. ¿Puedo ignorar el firewall del host?

Puedes hacerlo, hasta que un security group se aplique mal, se copie desde otro entorno o se cambie bajo presión. La defensa en profundidad no es un eslogan; es lo que evita que “un mal día” se convierta en “un mal trimestre”.

Siguientes pasos que puedes hacer hoy

  1. Audita cada host: ejecuta docker ps y ss -lntp. Anota qué está ligado a 0.0.0.0 y ::.
  2. Establece una política por defecto para entradas a contenedores: implementa un drop base para nuevas conexiones reenviadas en DOCKER-USER, y luego lista blanca explícitamente.
  3. Haz que publicar sea intencional: exige IPs de enlace explícitas en definiciones de compose/servicio; por defecto usa localhost y enruta a través de un proxy de borde.
  4. Prueba como un atacante: valida la alcanzabilidad desde fuera de tu segmento de red, incluyendo IPv6.
  5. Operativiza: persiste reglas, añade controles en CI/CD para puertos publicados no deseados y trata los cambios de firewall/Docker como cambios de producción con rollback.

Si recuerdas una cosa: Docker no “está evitando” tu firewall por malicia. Está usando el kernel exactamente como fue diseñado. Tu trabajo es poner la política en el lugar que el kernel realmente consulta.

← Anterior
¿No se detecta IOMMU en Proxmox? Arréglalo en 10 minutos (UEFI + flags del kernel)

Deja un comentario