Envías un contenedor. Publicas un puerto. Todo funciona en staging. Entonces alguien ejecuta un escaneo y encuentra tu interfaz de administración accesible desde internet público—por IPv6—mientras tu firewall IPv4 parece impecable.
Este modo de fallo es común, sutil y humillante justo de la manera que prefieren los incidentes en producción: la configuración está “correcta”, la intención es “segura” y aun así el tráfico llega. Pongamos fin a eso.
Qué es realmente una “filtración IPv6” (en términos de Docker)
Cuando la gente dice “filtración IPv6 en Docker”, normalmente se refiere a una de tres cosas:
-
Los puertos publicados son accesibles vía IPv6 aunque el operador solo consideró IPv4.
Ejemplo: hiciste-p 8080:80, asumiste que se enlaza a0.0.0.0solamente, y olvidaste que en algunos sistemas también se enlaza a::(todas las direcciones IPv6). Ahora el servicio está expuesto a internet para cualquiera que alcance la dirección IPv6 global del host. -
El filtrado cubre IPv4 pero no IPv6.
Endureciste iptables pero dejaste ip6tables/nftables v6 permisivo. El resultado es una postura de seguridad dividida: “bloqueado” en una familia de protocolos, abierto en la otra. -
El comportamiento de NAT/forwarding de Docker difiere para IPv6.
El networking IPv4 de Docker a menudo depende de NAT y patrones conocidos de iptables. IPv6, por diseño, espera enrutabilidad. Si no filtras explícitamente forwarding e input para IPv6, puedes dar contenedores direcciones accesibles globalmente o permitir forwarding entrante que no pretendías.
El término “filtración” es emocionalmente acertado: parece que los datos se filtraron por una costura que no conocías. Técnicamente, no es una filtración. Es un socket alcanzable, creado por comportamientos por defecto y la ausencia de una política explícita.
Por qué ocurre: la mecánica que te traiciona
1) Semántica de binding: 0.0.0.0 no es ::, y “todas las interfaces” es ambiguo
En Docker, -p 8080:80 significa “publica este puerto del contenedor en el host”. En muchas configuraciones Docker publica en todas las interfaces del host por defecto. En sistemas dual-stack, “todas las interfaces” puede incluir IPv6.
Si un puerto aparece en IPv6 depende del comportamiento dual-stack del kernel, del modo proxy de Docker y de cómo tu distribución trata net.ipv6.bindv6only. Algunas aplicaciones solo hacen binding v4; otras hacen binding dual-stack por defecto. Docker puede publicar mediante reglas de iptables y/o un proxy en espacio de usuario dependiendo de la versión y la configuración.
2) Tu política de firewall solo es tan buena como la familia que aplicas
Si usas reglas de iptables como tu “límite de seguridad”, debes recordar que hay dos conjuntos de tablas: IPv4 y IPv6. O, en despliegues modernos, nftables donde igualmente debes asegurarte de que tus reglas cubran ip6 además de ip.
La clásica palmada en la frente: UFW configurado y “activo”, pero IPv6 deshabilitado dentro de UFW (o permitido por defecto), mientras el host tiene una dirección IPv6 global. Tu checklist de cumplimiento ve “firewall instalado”. Los atacantes ven “puerto abierto”.
3) Comportamiento de la cadena FORWARD de Docker y la vía de escape DOCKER-USER
Docker manipula el filtrado de paquetes para hacer la red de contenedores conveniente. Conveniencia es solo riesgo con mejor marketing. Docker añadirá reglas para permitir el forwarding a redes de contenedores y para implementar puertos publicados.
Docker también proporciona un hook crucial: DOCKER-USER. Se evalúa antes que las reglas propias de Docker. Si quieres una política como “solo permitir inbound desde estos CIDR” o “bloquear todo excepto puertos publicados específicos”, DOCKER-USER es donde lo fijas.
Pero muchos equipos implementan DOCKER-USER solo para IPv4, luego asumen paridad para IPv6. No es automática. Necesitas la misma intención en ip6tables/nftables.
4) IPv6 está diseñado para alcanzabilidad punto a punto
La escasez de IPv4 empujó a todos hacia NAT. Eso normalizó la idea de que “las IP privadas” son relativamente seguras por defecto. IPv6 cambia el modelo mental por defecto: se puede enrutar globalmente, así que debes filtrar intencionalmente. Cuando das a un contenedor un IPv6 global (o haces forwarding hacia él), vuelves a un mundo previo al NAT.
Traducción: deja de tratar NAT como un firewall. NAT es un efecto secundario, no un control. Tu control es tu política de filtrado y tus direcciones de bind.
5) Las plataformas cloud te entregan IPv6 aunque no lo pidas amablemente
En varias nubes, habilitar IPv6 en una VPC/subred o instancia es un “pequeño checkbox” que cambia el modelo de amenazas de cada puerto publicado en cada host. Si la instancia tiene un IPv6 global, y tu security group / firewall del host no lo bloquea, tu publicación de Docker puede ser pública.
Broma #1: IPv6 no da miedo. Es solo IPv4 con suficiente espacio de direcciones para asignar una a cada tostadora, incluida la de la sala de descanso que “misteriosamente” se reinicia.
Hechos interesantes y contexto histórico (IPv6 + contenedores)
- IPv6 se estandarizó a finales de los 90, y la RFC central se actualizó más tarde; ha sido “el futuro” por más tiempo del que existen algunos sistemas en producción.
- El networking temprano de Docker (alrededor de 2013–2014) dependía mucho de iptables, y muchos equipos aprendieron “Docker equivale a NAT” como una regla natural. IPv6 complica esa suposición.
- IPv6 eliminó la suma de comprobación del encabezado para acelerar el ruteo; operativamente, trasladó complejidad a los endpoints y a las cabeceras de extensión—bueno para rendimiento, mixto para herramientas de seguridad.
- “Happy Eyeballs” (carrera de conexiones dual-stack) hizo que los clientes eligieran v6 o v4 dinámicamente; los operadores a veces solucionan el protocolo equivocado porque el cliente silenciosamente prefirió IPv6.
- Linux tiene soporte robusto para IPv6 desde hace décadas, pero las políticas de firewall por defecto a menudo se quedaban atrás—muchas distribuciones históricamente venían con reglas IPv6 permisivas incluso cuando IPv4 estaba bloqueado.
- Extensiones de privacidad IPv6 (direcciones temporales) redujeron el rastreo, pero también complican las listas de permitidos y la respuesta a incidentes porque las direcciones de host rotan.
- El “proxy en espacio de usuario” de Docker solía ser más común; en setups modernos a menudo se confía en reglas NAT del kernel. El camino en el que estés puede cambiar lo que significa “escuchando en ::”.
- La asignación de direcciones en contenedores difiere por driver: redes bridge, host networking, macvlan/ipvlan—el riesgo de exposición por IPv6 no es uniforme. Algunos drivers hacen la enrutabilidad trivial.
- Muchos marcos de cumplimiento históricamente se enfocaron en IPv4, por lo que auditorías “aprobaban” mientras producción era accesible por IPv6. No es malicia; es inercia.
Guía de diagnóstico rápido (comprueba primero/segundo/tercero)
Cuando sospechas “filtración IPv6 en Docker” y quieres una respuesta antes de que llegue la siguiente invitación de reunión, haz esto en orden:
Primero: confirma que el host realmente tiene IPv6 global alcanzable
- ¿Tiene el host una dirección IPv6 global en una interfaz pública?
- ¿Existe una ruta IPv6 por defecto?
- ¿Llega tráfico IPv6 entrante al host en absoluto (security group / firewall perimetral)?
Si el host no es alcanzable globalmente vía IPv6, tu problema probablemente sea exposición lateral interna (sigue siendo malo, pero la corrección es distinta).
Segundo: identifica qué está escuchando en IPv6 y por qué
- ¿Está el servicio enlazado a
::en el host? - ¿Docker está publicando el puerto en v6 o solo reenvía?
- ¿El contenedor en sí escucha en IPv6 dentro de su namespace?
Tercero: localiza la brecha de política (filtrado vs forwarding)
- Cadena Input: ¿el host permite inbound al puerto publicado en IPv6?
- Cadena Forward: ¿se reenvía tráfico hacia el bridge de docker en IPv6?
- DOCKER-USER: ¿tienes denegaciones/whitelists explícitas para v4 y v6?
Cuarto: decide tu intención
Necesitas una de estas intenciones explícitas, no sensaciones:
- “No IPv6 en este host.” Desactívalo en el host y en la capa de Docker.
- “IPv6 permitido, pero nada público por defecto.” Denegar por defecto inbound y forward para v6; permitir solo lo que realmente quieres.
- “IPv6 público está bien, pero solo en estos servicios.” Publicar/enlazar explícitamente y filtrar con precisión.
Tareas prácticas (comandos, salidas y decisiones)
A continuación hay tareas probadas en campo. Cada una incluye: un comando, una salida de ejemplo, qué significa y la decisión que tomas a continuación.
Ejecútalas como root o con sudo según corresponda; el objetivo es obtener respuestas, no ganar un concurso de pureza de privilegios.
Task 1: See if your host has a global IPv6 address
cr0x@server:~$ ip -6 addr show scope global
2: eth0 inet6 2001:db8:1234:5678:abcd:ef01:2345:6789/64 scope global dynamic
valid_lft 86234sec preferred_lft 14234sec
Significado: Tienes un IPv6 global en eth0. Si tu firewall es permisivo, los puertos publicados de Docker pueden ser alcanzables desde internet.
Decisión: Si no necesitas IPv6, planea desactivarlo. Si lo necesitas, aplica filtrado IPv6 explícito.
Task 2: Confirm you have an IPv6 default route
cr0x@server:~$ ip -6 route show default
default via fe80::1 dev eth0 proto ra metric 100 pref medium
Significado: El host puede alcanzar internet IPv6. Si el inbound también está permitido aguas arriba, estás en la zona de impacto.
Decisión: Trata este host como capaz de acceder a internet por IPv6; audita sockets en escucha y reglas de firewall ahora, no “después del sprint”.
Task 3: List listening sockets and spot v6 binds
cr0x@server:~$ ss -lntp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=911,fd=3))
LISTEN 0 4096 [::]:8080 [::]:* users:(("docker-proxy",pid=2341,fd=4))
Significado: El puerto 8080 está escuchando en todas las direcciones IPv6 ([::]) vía docker-proxy. Ese es tu vector de exposición pública.
Decisión: O bien enlázalo a una dirección específica, quita la publicación o bloquéalo con firewall en IPv6.
Task 4: Find which container published the port
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
billing-api 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
metrics-sidecar 127.0.0.1:9100->9100/tcp
Significado: El contenedor billing-api está publicado en IPv4 e IPv6. El puerto de métricas está correctamente restringido a loopback.
Decisión: Si billing-api no debe ser público, cambia la publicación a 127.0.0.1:8080:80 (y [::1] si es necesario) o elimina la publicación en el host por completo.
Task 5: Inspect Docker daemon IPv6 settings
cr0x@server:~$ cat /etc/docker/daemon.json
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef::/48",
"ip6tables": true
}
Significado: IPv6 de Docker está habilitado y Docker intentará gestionar reglas de ip6tables. Esto no es inherentemente inseguro—pero no constituye una política de seguridad.
Decisión: Si no necesitas IPv6 dentro de Docker, establece "ipv6": false (y elimina las configuraciones de CIDR v6). Si lo necesitas, aplica deny-by-default en DOCKER-USER para IPv6.
Task 6: Check whether Docker is using nftables or legacy iptables tooling
cr0x@server:~$ update-alternatives --display iptables | sed -n '1,8p'
iptables - auto mode
link best version is /usr/sbin/iptables-nft
link currently points to /usr/sbin/iptables-nft
link iptables is /usr/sbin/iptables
Significado: Estás en el backend iptables-nft. Las reglas siguen funcionando, pero investigar requiere entender nftables bajo el capó.
Decisión: Usa nft list ruleset para confirmar que existe filtrado IPv6; no asumas que la salida de iptables muestra todo si otra herramienta gestiona nft.
Task 7: Inspect IPv6 filter policy (ip6tables) and look for “ACCEPT all”
cr0x@server:~$ ip6tables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER
Significado: Política por defecto ACCEPT en INPUT/FORWARD para IPv6. Eso no es “abierto”, es “bienvenido activamente”.
Decisión: Pasa a deny-by-default, o al menos añade una política fuerte en DOCKER-USER y ajusta INPUT solo a los servicios requeridos.
Task 8: Confirm Docker’s IPv6 forwarding rules exist (and aren’t your only control)
cr0x@server:~$ ip6tables -L FORWARD -n -v --line-numbers | sed -n '1,20p'
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-USER all * * ::/0 ::/0
2 0 0 DOCKER all * * ::/0 ::/0
Significado: Docker ha insertado hooks. Si DOCKER-USER está vacío, el tráfico queda efectivamente permitido. Docker hizo su parte (plomería), no la tuya (política).
Decisión: Poblá DOCKER-USER para IPv6 y aplica tu intención real de exposición.
Task 9: Add an IPv6 default-deny policy in DOCKER-USER (carefully)
cr0x@server:~$ ip6tables -I DOCKER-USER 1 -i eth0 -p tcp -m multiport --dports 80,443 -j ACCEPT
cr0x@server:~$ ip6tables -I DOCKER-USER 2 -i eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ ip6tables -A DOCKER-USER -i eth0 -j DROP
cr0x@server:~$ ip6tables -L DOCKER-USER -n -v --line-numbers
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 ACCEPT tcp eth0 * ::/0 ::/0 multiport dports 80,443
2 120 9540 ACCEPT all eth0 * ::/0 ::/0 ctstate RELATED,ESTABLISHED
3 4 240 DROP all eth0 * ::/0 ::/0
Significado: Permites solo 80/443 entrante sobre IPv6 al tráfico reenviado hacia contenedores en eth0, además de conexiones establecidas; todo lo demás se descarta.
Decisión: Si los puertos de tus contenedores nunca deben ser alcanzables desde la interfaz pública, mantén el DROP. Si necesitas puertos específicos, añádelos a la lista de permitidos explícitamente.
Task 10: Verify published ports in a way humans can read
cr0x@server:~$ docker port billing-api
80/tcp -> 0.0.0.0:8080
80/tcp -> [::]:8080
Significado: La publicación dual-stack es real, no teórica.
Decisión: Si quieres “solo interno”, vuelve a publicar a loopback o elimina la publicación y usa un reverse proxy con binds estrictos.
Task 11: Check kernel forwarding and IPv6 RA behavior that can surprise you
cr0x@server:~$ sysctl net.ipv6.conf.all.forwarding net.ipv6.conf.default.accept_ra
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.default.accept_ra = 2
Significado: El forwarding IPv6 está habilitado. Esto está bien para routers, riesgoso para hosts de uso general porque cambia cómo atraviesan los paquetes las interfaces. accept_ra=2 significa aceptar anuncios de enrutador incluso cuando forwarding está habilitado—útil en algunas nubes, también una trampa si no entiendes la intención de ruteo.
Decisión: Si este no es un host de ruteo, considera desactivar forwarding y controlar la exposición de Docker vía reglas INPUT y binds publicados. Si necesitas forwarding, asegúrate de bloquear explícitamente políticas FORWARD.
Task 12: See if a container has IPv6 addresses and routes
cr0x@server:~$ docker exec -it billing-api sh -lc 'ip -6 addr; ip -6 route'
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
inet6 ::1/128 scope host
42: eth0@if43: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
inet6 fd00:dead:beef::42/64 scope global
default via fd00:dead:beef::1 dev eth0 metric 1024
Significado: El contenedor tiene una dirección IPv6 estable en un prefijo ULA. Eso no es enrutable por internet por sí mismo, pero es alcanzable dondequiera que se rutee ese prefijo (a menudo “dentro de la org”), lo cual puede seguir siendo demasiado amplio.
Decisión: Decide si los contenedores deben tener IPv6. Si la respuesta es sí, decide dónde se rutea ese prefijo y filtra en consecuencia.
Task 13: Confirm what Docker thinks about the network’s IPv6 config
cr0x@server:~$ docker network inspect bridge --format '{{json .EnableIPv6}} {{json .IPAM.Config}}'
false [{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]
Significado: El bridge por defecto tiene IPv6 deshabilitado aquí. Si aún ves exposición IPv6, probablemente sea vía puertos publicados en el host, no por dirección v6 asignada al contenedor.
Decisión: Enfócate en sockets en escucha del host y en el firewall del host en lugar de la direccionabilidad del contenedor.
Task 14: Test reachability from the outside (or simulate it)
cr0x@server:~$ curl -g -6 -v 'http://[2001:db8:1234:5678:abcd:ef01:2345:6789]:8080/' 2>&1 | sed -n '1,12p'
* Trying 2001:db8:1234:5678:abcd:ef01:2345:6789:8080...
* Connected to 2001:db8:1234:5678:abcd:ef01:2345:6789 (2001:db8:1234:5678:abcd:ef01:2345:6789) port 8080 (#0)
> GET / HTTP/1.1
> Host: [2001:db8:1234:5678:abcd:ef01:2345:6789]:8080
> User-Agent: curl/7.88.1
> Accept: */*
Significado: Si esto conecta desde un host fuera de tu red, tienes exposición pública. Si solo conecta internamente, aún tienes exposición—solo a una población distinta (empleados, usuarios de VPN, cargas de trabajo vecinas).
Decisión: Si esto no debe ser alcanzable, detén el bind y/o bloquéalo ahora. No esperes a que un ticket se convierta en postmortem.
Task 15: Check nftables ruleset for IPv6 coverage (modern systems)
cr0x@server:~$ nft list ruleset | sed -n '1,80p'
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif "lo" accept
tcp dport 22 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
}
}
Significado: Usar una tabla inet cubre tanto IPv4 como IPv6 con un único conjunto de reglas. Eso suele ser lo que quieres: menos posibilidades de “olvidar IPv6”.
Decisión: Si no usas una tabla de familia inet (o equivalente), considera migrar seriamente. Mantener políticas v4/v6 por separado es donde nacen las filtraciones.
Task 16: Audit Docker’s ip6tables toggle (and don’t assume it saves you)
cr0x@server:~$ docker info | sed -n '1,60p' | grep -E 'IPv6|iptables|Security Options'
IPv6: true
iptables: true
Security Options:
apparmor
seccomp
Significado: Docker programará reglas, pero no impondrá tu intención. El trabajo de Docker es conectividad; tu trabajo es “conectividad con restricciones”.
Decisión: Trata esto como “plomería presente”. Aun así implementa deny-by-default y listas de permitidos explícitas.
Tres mini-historias del mundo corporativo
Mini-historia 1: El incidente causado por una suposición errónea
Una empresa SaaS mediana desplegó un nuevo dashboard interno. Se suponía que solo sería accesible a través de la VPN corporativa. La app vivía en un contenedor Docker detrás de una simple publicación -p 3000:3000 en un host de servicios. El acceso IPv4 estaba bloqueado en el perímetro; todo iba bien.
La suposición errónea fue silenciosa: “Si IPv4 está bloqueado, está bloqueado”. Sus reglas de security group eran centradas en IPv4 y las reglas del host eran solo iptables. Mientras tanto, el equipo de red de la nube habilitó IPv6 en la subred como parte de una migración más amplia, porque “lo vamos a necesitar eventualmente”.
En un día, un escáner automático encontró el dashboard en el IPv6 global del host. No por una explotación ingeniosa—solo una conexión TCP simple al puerto publicado. El dashboard requería login, pero tenía un flujo de restablecimiento de contraseña con enumeración de correo. Eso se convirtió en un incidente medible: revisión de seguridad, restablecimientos forzados, comunicaciones internas incómodas.
El postmortem fue doloroso por una razón: nadie hizo nada “raro”. Docker hizo lo que sabe hacer. La nube hizo lo suyo. El firewall hizo exactamente lo que se le dijo—en IPv4.
La solución fue aburrida y efectiva: drop por defecto en inbound IPv6 en el host, listas de permitidos explícitas para servicios publicados y una regla de que cada publicación de servicio debe especificar una dirección, nunca “todas las interfaces” implícitas.
Mini-historia 2: La optimización que salió mal
Otra compañía se volvió seria con la latencia y eliminó un salto de reverse proxy. Pasaron de “Nginx en el host termina TLS y hace proxy hacia redes Docker” a “publicar puertos de contenedores directamente en el host para menos partes móviles”. Menos overhead, menos configs, menos casos límite de recarga de certificados. En papel parecía limpio.
Lo que ganaron en microsegundos lo pagaron en exposición. El reverse proxy estaba enlazado a interfaces específicas, tenía listas de permitidos estrictas y una postura IPv6 deliberada. La publicación directa no heredó nada de eso. Varios servicios de repente escuchaban en [::] porque el nodo era dual-stack. Un par de endpoints administrativos “internos” ahora eran alcanzables desde cualquier red de oficina con IPv6—y desde internet público en un entorno donde los filtros aguas arriba eran permisivos.
El primer síntoma ni siquiera fue de seguridad. Fue comportamiento extraño del cliente: algunos clientes de oficina conectaban por IPv6, otros por IPv4, y la canalización de logging solo registraba cabeceras v4. Cuando respuesta a incidentes intentó trazar una petición, perseguían fantasmas: “No existe fuente IPv4 coincidente”.
Revirtieron al proxy, luego reintrodujeron publicación directa solo para servicios cuidadosamente seleccionados, con direcciones de bind explícitas y paridad de firewall dual-stack. La “optimización” no estaba equivocada; estaba incompleta. La pila de red no evalúa tu intención.
Mini-historia 3: La práctica aburrida pero correcta que salvó el día
Un equipo fintech tenía una política que parecía casi anticuada: cada cambio en la publicación de contenedores requería una verificación automática que comparara los sockets en escucha esperados con los reales. Era básicamente un diff script de ss + docker ps ejecutado en CI para cambios de infraestructura. Los ingenieros gimoteaban. Detectaba problemas aburridos. No era glamoroso.
Durante una reconstrucción rutinaria del host, la imagen base cambió y trajo una configuración IPv6 por defecto más nftables. Docker siguió funcionando, los servicios siguieron funcionando, y nadie notó nada. Pero la verificación marcó un nuevo listener: un endpoint interno de métricas ahora era alcanzable en [::]:9100.
El equipo lo trató como un defecto, no como curiosidad. Lo arreglaron enlazando métricas a loopback y añadiendo una regla nftables inet para descartar inbound inesperado por defecto. Sin incidente, sin pager, sin ventana de cambio de emergencia.
Este es el tipo de prevención que parece tiempo perdido hasta que ves la alternativa. También es una práctica que puedes justificar ante ejecutivos sin sonar alarmista: “Detenemos exposiciones accidentales antes de que lleguen a producción”.
Patrones de endurecimiento que realmente funcionan
Patrón A: Binds explícitos para cada puerto publicado
Si te llevas una sola cosa de este artículo, que sea esta: nunca publiques sin una dirección de bind explícita.
- Servicio interno: publica a
127.0.0.1(y opcionalmente::1si realmente necesitas loopback IPv6). - Servicio público: publica a la dirección específica de la interfaz pública (v4 y/o v6), no al comodín.
“Pero Docker Compose no lo hace fácil.” Sí lo hace. Solo tienes que ser explícito.
Patrón B: Denegar por defecto inbound en IPv6, luego permitir lo que quieres
Si el host tiene IPv6 global, trátalo como IPv4 público. Mismo nivel de seriedad, misma higiene. Eso significa:
- INPUT: descartar inbound excepto SSH (desde rangos allowlist), tu reverse proxy y lo realmente necesario.
- FORWARD: descartar por defecto; permitir flujos establecidos y solo el forwarding que pretendes hacia redes Docker.
- DOCKER-USER: pon tu política de exposición de contenedores aquí para que las actualizaciones de Docker no “útilmente” deshagan tu intención.
Patrón C: Preferir un único plano de políticas (nftables inet) cuando sea posible
Si tu plataforma soporta nftables de forma limpia, una tabla de familia inet te da un conjunto de reglas que aplica a IPv4 y IPv6. Menos conjuntos de reglas significa menos huecos.
Esto no te hace mágicamente seguro. Solo reduce el número de lugares donde puedes olvidar aplicar seguridad.
Patrón D: Si no necesitas IPv6 en Docker, desactívalo intencionalmente
Esto no es una declaración ideológica. Es operativa. Si no tienes un requisito IPv6 para contenedores, estás comprando complejidad sin beneficio.
Desactívalo en la capa de Docker y opcionalmente en el host, dependiendo de las necesidades de tu entorno. Desactivar a nivel de host puede tener efectos secundarios en nubes modernas, así que hazlo con conocimiento.
Patrón E: Pasa el ingreso público por un único punto de estrangulamiento
Un reverse proxy o ingress controller no es solo “otro salto”. Es donde centralizas:
- Política TLS y rotación de certificados
- Autenticación, limitación de tasa, límites de tamaño de petición
- Logs de acceso que realmente puedes correlacionar
- Comportamiento de bind v4/v6 explícito
La publicación directa de puertos está bien para servicios verdaderamente públicos con buena higiene. Es una trampa para servicios “internos” que nunca se diseñaron para el exterior.
Cita (idea parafraseada) de James Hamilton (ingeniería de confiabilidad Amazon/AWS): “Todo falla; diseña para que las fallas estén contenidas y sean recuperables.”
Broma #2: La primera regla del club IPv6 es que no hablas del club IPv6. La segunda regla es que tu contenedor sí lo hace.
Errores comunes: síntoma → causa raíz → solución
Estos son los que sigo viendo en sistemas reales. Los síntomas suelen ser confusos porque las comprobaciones IPv4 parecen estar bien.
1) Síntoma: “Nuestro puerto está bloqueado por el firewall, pero los escáneres aún lo alcanzan”
Causa raíz: El firewall IPv4 está configurado; IPv6 está por defecto permitido (o aguas arriba lo permite). El servicio está publicado en [::].
Solución: Añade filtrado IPv6 equivalente (nftables inet preferido), o enlaza las publicaciones a IPv4 solo, y verifica con ss -lntp.
2) Síntoma: “Solo algunos clientes pueden alcanzar el servicio; los logs no coinciden”
Causa raíz: Los clientes dual-stack prefieren IPv6 a veces. Tu pipeline de observabilidad y listas de permitidos eran solo IPv4.
Solución: Asegura que el logging capture IPv6 remotas, actualiza las listas de permitidos para incluir rangos IPv6, o deshabilita la exposición IPv6 para ese servicio.
3) Síntoma: “Deshabilitamos reglas iptables, pero Docker sigue exponiendo puertos”
Causa raíz: Proxy en espacio de usuario o listeners a nivel de host aún aceptan conexiones; o existen reglas nftables a pesar de la vista iptables.
Solución: Verifica listeners reales con ss. Inspecciona nftables con nft list ruleset. No confíes en la vista de una única herramienta.
4) Síntoma: “El contenedor tiene un IPv6 ULA, pero es alcanzable desde otras redes”
Causa raíz: Las ULA pueden ser ruteadas internamente. No son “privadas” en el sentido NAT; son “no globales”. El enrutamiento interno las hizo alcanzables.
Solución: Filtra en los límites, restringe rutas o evita asignar IPv6 a contenedores que no lo necesiten.
5) Síntoma: “Configuramos reglas DOCKER-USER; IPv6 aún se filtra”
Causa raíz: Las reglas se añadieron solo para iptables (IPv4), no ip6tables; o las reglas matchean la interfaz equivocada; o el tráfico llega a INPUT (listener del host) y no a FORWARD.
Solución: Replica la política en ip6tables o usa nftables inet. Confirma si el socket es a nivel de host (docker-proxy) vs reenviado.
6) Síntoma: “Desactivar IPv6 rompió actualizaciones de paquetes / metadata de la nube”
Causa raíz: Tu entorno espera IPv6 para ciertos endpoints, o DNS devuelve AAAA primero y el comportamiento del resolvedor cambia.
Solución: No desactives IPv6 a nivel de host sin pruebas. En su lugar, mantenlo activo pero denegar inbound por defecto y publicar explícitamente.
Listas de verificación / plan paso a paso
Paso a paso: Asegurar un host Docker que accidentalmente se volvió dual-stack
-
Inventario de exposición.
Ejecutass -lntpydocker pspara encontrar puertos publicados y listeners v6. -
Decide tu postura.
Elige uno: sin IPv6, IPv6 solo interno, o IPv6 público para servicios específicos. -
Corrige binds.
Actualiza definiciones de Compose/servicio para publicar con direcciones explícitas (loopback para interno). -
Aplica política en el firewall.
Usa nftablesinetcuando sea posible; de lo contrario replica iptables/ip6tables. -
Usa DOCKER-USER para política de reenvío de contenedores.
Denegar por defecto, permitir solo lo necesario. -
Verifica desde afuera.
Prueba alcance IPv6 con curl a la IPv6 global del host y a los puertos publicados. -
Hazlo permanente.
Persiste reglas de firewall. Asegura que reinicios de Docker no borren tu política. Añade una comprobación en CI que marque nuevos listeners.
Checklist: Cómo se ve “lo suficientemente seguro” para la mayoría de equipos
- Cada puerto publicado especifica una dirección de bind (sin comodines implícitos).
- Política de firewall deny-by-default para inbound IPv6 (y IPv4) con permisos explícitos.
- La cadena DOCKER-USER existe y contiene la intención de tu organización (v4 y v6).
- Al menos un escaneo/examen externo prueba alcance IPv6, no solo IPv4.
- Los logs incluyen direcciones IPv6 remotas y tus alertas no las descartan al parsear.
- Los security groups / NACLs de la nube incluyen reglas IPv6, revisadas igual que IPv4.
Checklist: Si realmente quieres “no IPv6 aquí”
- Daemon de Docker con
"ipv6": falsesalvo que sea necesario. - Firewall del host bloquea inbound IPv6 de todos modos (defensa en profundidad).
- Configuración de la red en la nube no asigna IPv6 global a la instancia salvo que sea requerido.
- El monitoreo sigue funcionando (algunos agentes prefieren IPv6).
Preguntas frecuentes
1) ¿Es esto un bug de Docker?
Usualmente no. Es Docker haciendo lo que fue diseñado para hacer: facilitar que la red funcione con mínima entrada del operador. El “bug” es asumir que mínima entrada equivale a mínima exposición.
2) Si mi contenedor solo escucha en IPv4, ¿puede aún ser alcanzable por IPv6?
Sí, dependiendo de cómo el host publique el puerto y si un proxy traduce/acepta en v6 y reenvía a v4 localmente. Confirma con ss en el host y pruebas de conexión reales.
3) ¿Enlazar a 127.0.0.1 es suficiente?
Para exposición IPv4, sí. Para IPv6 debes asegurarte también de no publicar en [::]. En Compose, sé explícito; no confíes en los valores por defecto. Verifica con docker ps y docker port.
4) ¿Debería deshabilitar IPv6 en todas partes para estar seguro?
Si no lo necesitas, deshabilitarlo puede reducir riesgo. Pero desactivar a nivel de host puede romper supuestos de red en la nube y tareas de resolución. Una opción más resiliente: mantener IPv6 habilitado, denegar inbound por defecto y publicar explícitamente.
5) ¿Por qué mi firewall IPv6 parece vacío aunque establecí reglas?
Puede que estés mirando ip6tables mientras el sistema realmente usa nftables, o viceversa. Siempre revisa listeners (ss) e inspecciona los rulesets de nftables si estás en iptables-nft.
6) ¿Cuál es el mejor lugar para imponer política de ingreso a contenedores?
La cadena DOCKER-USER (o su equivalente en nftables) es el mejor gancho “antes de que Docker haga lo suyo”. Está diseñada para la política del operador.
7) ¿Kubernetes tiene el mismo problema?
Mecánicas diferentes, misma clase de fallo. NodePorts, pods hostNetwork y el soporte IPv6 del CNI pueden exponer servicios vía IPv6 si el firewall del nodo y las reglas de la nube no son simétricas.
8) ¿Cómo demuestro que lo arreglé?
Tres pruebas: (1) ss -lntp no muestra listeners inesperados [::], (2) la política de firewall para IPv6 es explícita (deny-by-default o listas estrictas) y (3) una prueba de conexión IPv6 externa falla para puertos que deberían ser privados.
9) ¿Y “ipv6″: true en Docker—hace eso que las cosas sean públicas automáticamente?
No automáticamente. Habilitar IPv6 da a los contenedores direcciones v6 en redes configuradas y puede crear reglas de forwarding. La exposición pública sigue dependiendo de ruteo y filtrado. Pero aumenta las formas de sorprenderse, así que hazlo acompañado de política explícita.
Conclusión: siguientes pasos que puedes ejecutar
Las filtraciones IPv6 en Docker no son misteriosas. Son un resultado predecible de hosts dual-stack, publicaciones implícitas y políticas de firewall que solo hablan IPv4. No las arreglas con esperanza. Las arreglas con binds explícitos y reglas deny-by-default que cubran ambas familias de protocolo.
Haz esto a continuación, en este orden:
- Ejecuta
ip -6 addr,ss -lntpydocker pspara identificar la exposición real. - Decide si IPv6 es necesario en este host y en estos contenedores.
- Haz la publicación explícita (direcciones de bind) y centraliza el ingreso público cuando sea posible.
- Aplica la política en
DOCKER-USER(IPv4 e IPv6) o en nftables inet con deny-by-default. - Verifica desde el exterior y añade una comprobación automatizada para que esto no regrese silenciosamente el próximo trimestre.