Vous déployez un conteneur. Vous publiez un port. Tout fonctionne en staging. Puis quelqu’un lance un scan et trouve votre interface d’administration accessible depuis l’internet public — via IPv6 — alors que votre pare-feu IPv4 semble impeccable.
Ce mode de défaillance est courant, subtil et humiliant de la façon préférée des incidents en production : la configuration est « correcte », l’intention est « sécurisée », et le trafic arrive quand même. Arrêtons cela.
Ce qu’est réellement une « fuite IPv6 » (en termes Docker)
Quand on parle de « fuite IPv6 Docker », on entend généralement l’une des trois choses suivantes :
-
Des ports publiés sont accessibles via IPv6 alors que l’opérateur n’a considéré que l’IPv4.
Exemple : vous avez fait-p 8080:80, supposé que cela se lie uniquement à0.0.0.0, et oublié que sur certains systèmes cela se lie aussi à::(toutes les adresses IPv6). Le service devient alors accessible depuis l’internet pour quiconque peut atteindre l’adresse IPv6 globale de votre hôte. -
Le pare-feu couvre l’IPv4 mais pas l’IPv6.
Vous avez durci iptables mais laissé ip6tables/nftables v6 permissifs. Le résultat est une posture de sécurité à double visage : « bloqué » sur une famille de protocoles, grand ouvert sur l’autre. -
Le comportement NAT/forwarding de Docker diffère pour IPv6.
Le réseau Docker IPv4 s’appuie souvent sur le NAT et des motifs iptables bien connus. L’IPv6, par conception, attend la routabilité. Si vous ne filtrez pas explicitement le forwarding et l’input pour IPv6, vous pouvez donner accidentellement aux conteneurs des adresses globalement atteignables ou autoriser un forwarding entrant que vous ne souhaitiez pas.
Le terme « fuite » est émotionnellement approprié : on a l’impression que des données se sont faufilées par une couture inconnue. Techniquement, ce n’est pas une fuite. C’est une socket accessible, créée par des comportements par défaut et l’absence d’une politique explicite.
Pourquoi cela arrive : les mécanismes qui vous trahissent
1) Sémantique des binds : 0.0.0.0 n’est pas ::, et « toutes les interfaces » est ambigu
Dans Docker, -p 8080:80 signifie « publier ce port du conteneur sur l’hôte ». Sur de nombreuses configurations Docker publie par défaut sur toutes les interfaces de l’hôte. Sur des systèmes dual-stack, « toutes les interfaces » peut inclure IPv6.
Le fait qu’un port apparaisse sur IPv6 dépend du comportement dual-stack du noyau, du mode proxy de Docker, et de la façon dont votre distribution traite net.ipv6.bindv6only. Certaines applications ne bindent que v4 ; d’autres bindent dual-stack par défaut. Docker peut publier via des règles iptables et/ou un proxy en espace utilisateur selon la version et la configuration.
2) Votre politique de pare-feu n’est aussi bonne que la famille que vous appliquez
Si vous utilisez des règles iptables comme « frontière de sécurité », souvenez-vous qu’il y a deux ensembles de tables : IPv4 et IPv6. Ou, dans des déploiements modernes, nftables où vous devez aussi vous assurer que vos règles couvrent ip6 autant que ip.
Le classique facepalm : UFW configuré et « actif », mais IPv6 désactivé dans UFW (ou autorisé par défaut), tandis que l’hôte a une adresse IPv6 globale. Votre checklist de conformité voit « pare-feu installé ». Les attaquants voient « port ouvert ».
3) Comportement de la chaîne FORWARD de Docker et le point d’entrée DOCKER-USER
Docker manipule le filtrage des paquets pour rendre le réseau des conteneurs commode. La commodité n’est que du risque avec un meilleur marketing. Docker ajoutera des règles pour autoriser le forwarding vers les réseaux de conteneurs et pour implémenter les ports publiés.
Docker fournit aussi un crochet crucial : DOCKER-USER. Il est évalué avant les propres règles de Docker. Si vous voulez une politique comme « n’autoriser que ces CIDR entrants » ou « bloquer tout sauf les ports publiés spécifiques », DOCKER-USER est l’endroit où l’énoncer.
Mais beaucoup d’équipes implémentent DOCKER-USER pour l’IPv4 uniquement, puis supposent la parité pour l’IPv6. Ce n’est pas automatique. Vous avez besoin de la même intention dans ip6tables/nftables.
4) L’IPv6 est conçu pour la reachabilité de bout en bout
La rareté de l’IPv4 a poussé tout le monde vers le NAT. Cela a normalisé l’idée que « les IP privées » sont relativement sûres par défaut. L’IPv6 inverse le modèle mental par défaut : vous pouvez router globalement, donc vous devez filtrer intentionnellement. Quand vous donnez à un conteneur une IPv6 globale (ou faites du forwarding vers lui), vous revenez à un monde d’avant le NAT.
Traduction : arrêtez de considérer le NAT comme un pare-feu. Le NAT est un effet secondaire, pas un contrôle. Votre contrôle, c’est votre politique de filtrage et vos adresses de bind.
5) Les plateformes cloud vous fournissent IPv6, que vous l’ayez demandé ou pas
Dans plusieurs clouds, activer IPv6 sur une VPC/sous-réseau ou une instance est une « petite case à cocher » qui change le modèle de menace de chaque port publié sur chaque hôte. Si l’instance a une IPv6 globale, et que votre security group / pare-feu hôte ne la bloque pas, votre publication Docker peut être publique.
Blague #1 : IPv6 n’est pas effrayant. C’est juste l’IPv4 avec suffisamment d’espace d’adresses pour attribuer une adresse à chaque grille-pain, y compris celui de la salle de pause qui redémarre « mystérieusement ».
Faits intéressants & contexte historique (IPv6 + conteneurs)
- IPv6 a été standardisé à la fin des années 1990, et le RFC principal a été mis à jour par la suite ; il a été « le futur » plus longtemps que certains systèmes de production n’existent.
- Les premiers réseaux Docker (vers 2013–2014) s’appuyaient fortement sur iptables, et beaucoup d’équipes ont appris « Docker = NAT » comme une loi de la nature. L’IPv6 complique cette hypothèse.
- IPv6 a retiré la somme de contrôle de l’en-tête pour accélérer le routage ; opérationnellement, cela a déplacé de la complexité vers les endpoints et les en-têtes d’extension — bon pour la performance, mitigé pour les outils de sécurité.
- « Happy Eyeballs » (course de connexion dual-stack) a fait que les clients choisissent v6 ou v4 dynamiquement ; les opérateurs résolvent parfois le mauvais protocole parce que le client a silencieusement préféré IPv6.
- Linux a un support IPv6 solide depuis des décennies, mais les politiques de pare-feu par défaut ont souvent pris du retard — beaucoup de distributions ont historiquement livré des règles IPv6 permissives même quand l’IPv4 était verrouillé.
- Les extensions de confidentialité IPv6 (adresses temporaires) ont réduit le suivi, mais compliquent aussi les listes d’autorisation et la réponse aux incidents parce que les adresses hôtes tournent.
- Le « userland proxy » de Docker était autrefois plus courant ; les configurations modernes reposent souvent sur des règles NAT du noyau. Le chemin sur lequel vous êtes peut changer ce que « écouter sur :: » signifie.
- L’adressage des conteneurs diffère selon le driver : réseaux bridge, host networking, macvlan/ipvlan — le risque d’exposition IPv6 n’est pas uniforme. Certains drivers rendent la routabilité triviale.
- Beaucoup de référentiels de conformité se concentraient historiquement sur l’IPv4, donc des audits « réussis » laissaient la production accessible via IPv6. Ce n’est pas de la malveillance ; c’est de l’inertie.
Mode d’intervention rapide (vérifier d’abord/deuxième/troisième)
Quand vous suspectez une « fuite IPv6 Docker » et que vous voulez une réponse avant que la prochaine invitation de réunion n’arrive, faites ceci dans l’ordre :
Premier : confirmer que l’hôte a effectivement une IPv6 globalement atteignable
- L’hôte a-t-il une adresse IPv6 globale sur une interface publique ?
- Y a-t-il une route IPv6 par défaut ?
- L’IPv6 entrante atteint-elle l’hôte du tout (security group / pare-feu en bordure) ?
Si l’hôte n’est pas atteignable globalement via IPv6, votre problème est probablement une exposition latérale interne (toujours mauvais, correction différente).
Deuxième : identifier ce qui écoute sur IPv6 et pourquoi
- Le service est-il lié à
::sur l’hôte ? - Docker publie-t-il le port sur v6 ou seulement le forwarde-t-il ?
- Le conteneur lui-même écoute-t-il en IPv6 à l’intérieur de son namespace ?
Troisième : localiser le point de défaillance de la politique (filtrage vs forwarding)
- Chaîne Input : l’hôte autorise-t-il l’entrée vers le port publié sur IPv6 ?
- Chaîne Forward : le trafic est-il forwardé dans le bridge Docker sur IPv6 ?
- DOCKER-USER : avez-vous des deny/allowlists explicites pour v4 et v6 ?
Quatrième : décider de votre intention
Vous avez besoin d’une de ces intentions explicites, pas d’impressions :
- « Pas d’IPv6 sur cet hôte. » Désactivez-le au niveau de l’hôte et de Docker.
- « IPv6 autorisé, mais rien de public par défaut. » Default-deny inbound et forward pour v6 ; n’autorisez que ce que vous voulez.
- « IPv6 public OK, mais seulement pour ces services. » Publiez en bind explicite et filtrez précisément.
Tâches pratiques (commandes, sorties, et décisions)
Ci-dessous des tâches éprouvées sur le terrain. Chaque item inclut : une commande, une sortie d’exemple, ce que cela signifie et la décision suivante. Exécutez-les en root ou avec sudo lorsque c’est approprié ; l’objectif est d’obtenir des réponses, pas de gagner un concours de pureté de privilèges.
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
Signification : Vous avez une IPv6 globale sur eth0. Si votre pare-feu est permissif, les ports Docker publiés peuvent être atteignables depuis internet.
Décision : Si vous n’avez pas besoin d’IPv6, planifiez sa désactivation. Si vous en avez besoin, appliquez explicitement un filtrage IPv6.
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
Signification : L’hôte peut atteindre l’internet IPv6. Si l’entrant est aussi autorisé en amont, vous êtes dans le rayon d’impact.
Décision : Traitez cet hôte comme apte à l’internet sur IPv6 ; auditez immédiatement les sockets écoutants et les règles du pare-feu.
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))
Signification : Le port 8080 écoute sur toutes les adresses IPv6 ([::]) via docker-proxy. C’est votre vecteur d’exposition public.
Décision : Soit liez-le à une adresse spécifique, supprimez la publication, soit bloquez-le au pare-feu pour 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
Signification : Le conteneur billing-api est publié sur IPv4 et IPv6. Le port metrics est correctement contraint au loopback.
Décision : Si billing-api ne doit pas être public, republiquez sur 127.0.0.1:8080:80 (et [::1] si nécessaire) ou supprimez complètement la publication sur l’hôte.
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
}
Signification : Docker IPv6 est activé et Docker tentera de gérer les règles ip6tables. Ce n’est pas intrinsèquement dangereux — mais ce n’est pas une politique de sécurité.
Décision : Si vous n’avez pas besoin d’IPv6 dans Docker, mettez "ipv6": false (et retirez les paramètres de CIDR v6). Si vous en avez besoin, appliquez default-deny dans DOCKER-USER pour l’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
Signification : Vous êtes sur le backend iptables-nft. Les règles fonctionnent toujours, mais le dépannage exige de comprendre nftables en dessous.
Décision : Utilisez nft list ruleset pour confirmer la présence d’un filtrage IPv6 ; ne supposez pas que la sortie iptables raconte toute l’histoire si d’autres outils gèrent 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
Signification : Default ACCEPT sur INPUT/FORWARD pour IPv6. Ce n’est pas « ouvert », c’est « activement accueillant ».
Décision : Passez à default-deny, ou au moins ajoutez une politique forte dans DOCKER-USER et resserrez INPUT aux seuls services requis.
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
Signification : Docker a inséré des hooks. Si DOCKER-USER est vide, le trafic est effectivement autorisé. Docker a fait sa part (la plomberie), pas la vôtre (la politique).
Décision : Remplissez DOCKER-USER pour l’IPv6 afin d’appliquer votre intention d’exposition réelle.
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
Signification : Vous n’autorisez que l’entrée 80/443 sur IPv6 vers le trafic forwardé vers les conteneurs sur eth0, plus les connexions établies ; tout le reste est DROP.
Décision : Si vos ports conteneur ne doivent jamais être atteignables depuis l’interface publique, conservez le DROP. Si vous avez besoin de ports spécifiques, autorisez-les explicitement.
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
Signification : La publication dual-stack est réelle, pas théorique.
Décision : Si vous voulez « interne uniquement », republiquez sur loopback ou supprimez la publication et utilisez un reverse proxy avec des binds stricts.
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
Signification : Le forwarding IPv6 est activé. C’est correct pour des routeurs, risqué pour des hôtes généralistes car cela change la façon dont les paquets traversent les interfaces. accept_ra=2 signifie accepter les annonces de routeur même quand le forwarding est activé — utile dans certains clouds, aussi un piège si vous ne comprenez pas l’intention de routage.
Décision : Si ce n’est pas un hôte de routage, envisagez de désactiver le forwarding et de contrôler l’exposition Docker via des règles d’entrée et des binds publiés. Si vous avez besoin du forwarding, verrouillez explicitement les politiques 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
Signification : Le conteneur a une adresse IPv6 stable sur un préfixe ULA. Ce n’est pas routable sur l’internet global par lui-même, mais il est atteignable là où ce préfixe est routé (souvent « à l’intérieur de l’organisation », ce qui peut rester trop large).
Décision : Décidez si les conteneurs doivent avoir IPv6 du tout. Si oui, décidez où ce préfixe est routé et filtrez en conséquence.
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"}]
Signification : L’IPv6 du bridge par défaut est désactivée ici. Si vous voyez quand même une exposition IPv6, elle est probablement via des ports publiés sur l’hôte, pas via des adresses v6 assignées aux conteneurs.
Décision : Concentrez-vous sur les sockets écoutants de l’hôte et le pare-feu de l’hôte plutôt que sur l’adressabilité des conteneurs.
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: */*
Signification : Si cela se connecte depuis un hôte extérieur à votre réseau, vous avez une exposition publique. Si cela ne se connecte qu’en interne, vous avez toujours une exposition — simplement à une population différente (employés, utilisateurs VPN, charges voisines).
Décision : Si cela ne doit pas être atteignable, arrêtez le bind et/ou bloquez-le au pare-feu maintenant. N’attendez pas qu’un ticket devienne un 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
}
}
Signification : L’utilisation d’une table inet couvre IPv4 et IPv6 avec un seul jeu de règles. C’est généralement ce que vous voulez : moins d’occasions d’« oublier IPv6 ».
Décision : Si vous n’utilisez pas une table de famille inet (ou équivalent), envisagez sérieusement la migration. La maintenance double des politiques v4/v6 est là où naissent les fuites.
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
Signification : Docker programmera des règles, mais n’imposera pas votre intention. Le travail de Docker est la connectivité ; votre travail est la « connectivité avec contraintes ».
Décision : Considérez ceci comme « plomberie présente ». Implementez quand même deny-by-default et des listes d’autorisation explicites.
Trois mini-récits du monde de l’entreprise
Mini-récit 1 : L’incident causé par une mauvaise hypothèse
Une entreprise SaaS de taille moyenne a déployé un nouveau tableau de bord interne. Il devait être accessible uniquement via un VPN d’entreprise. L’application vivait dans un conteneur Docker derrière un simple -p 3000:3000 publié sur un hôte utilitaire. L’accès IPv4 était bloqué à la frontière ; tout allait bien.
L’hypothèse erronée était discrète : « Si IPv4 est bloqué, c’est bloqué. » Leurs règles de security group étaient centrées IPv4 et leurs règles hôtes étaient uniquement iptables. Pendant ce temps, l’équipe réseau cloud a activé IPv6 sur le sous-réseau dans le cadre d’une migration plus large, parce que « on en aura besoin un jour ».
En moins d’un jour, un scanner automatisé a trouvé le tableau de bord sur l’IPv6 globale de l’hôte. Pas via une astuce sophistiquée — juste une connexion TCP simple sur le port publié. Le tableau de bord demandait une authentification, mais il avait un flux de réinitialisation de mot de passe avec énumération d’emails. Cela a généré un incident mesurable : revue de sécurité, réinitialisations forcées, communications internes gênantes.
Le postmortem a été douloureux pour une raison : personne n’a fait quelque chose de « bizarre ». Docker a fait ce qu’il sait faire. Le cloud a fait ce qu’il sait faire. Le pare-feu a fait exactement ce qu’on lui a dit — sur IPv4.
La correction a été ennuyeuse et efficace : default-drop inbound sur IPv6 à l’hôte, allowlists explicites pour les services publiés, et une règle stipulant que chaque publication de service doit préciser une adresse, jamais l’implicite « toutes les interfaces ».
Mini-récit 2 : L’optimisation qui a eu l’effet inverse
Une autre entreprise a cherché à optimiser la latence et a retiré un saut de reverse proxy. Ils sont passés de « Nginx hôte termine TLS et proxy dans les réseaux Docker » à « publier directement les ports conteneur sur l’hôte pour moins d’éléments ». Moins de surcharge, moins de configs, moins de cas limites de rechargement de certificats. Sur le papier, c’était propre.
Ce qu’ils ont gagné en microsecondes, ils l’ont payé en exposition. Le proxy était lié à des interfaces spécifiques, avait des allowlists strictes, et une posture IPv6 délibérée. La publication directe n’a rien hérité de cela. Plusieurs services ont soudainement écouté sur [::] parce que le nœud était dual-stack. Quelques endpoints d’administration « internes » étaient désormais atteignables depuis n’importe quel réseau de bureau IPv6-capable — et depuis l’internet public dans un environnement où les filtres en amont étaient permissifs.
Le premier symptôme n’était même pas de la sécurité. C’était un comportement client étrange : certains clients de bureau se connectaient en IPv6, d’autres en IPv4, et la pipeline de logs ne consignait que les en-têtes v4. Quand l’équipe d’incident a essayé de retracer une requête, elle a poursuivi des fantômes : « Aucune source IPv4 correspondante n’existe. »
Ils sont revenus au proxy, puis ont réintroduit la publication directe seulement pour des services soigneusement sélectionnés, avec des adresses de bind explicites et la parité des pare-feu dual-stack. L’« optimisation » n’était pas fausse ; elle était incomplète. La pile réseau ne note pas vos intentions.
Mini-récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe fintech avait une politique presque old-school : chaque changement de publication de conteneur nécessitait une vérification automatisée comparant les sockets attendus aux sockets réels. C’était essentiellement un diff scripté ss + docker ps exécuté en CI pour les changements d’infrastructure. Les ingénieurs gémissaient. Cela a attrapé des problèmes ennuyeux. Ce n’était pas glamour.
Lors d’une reconstruction d’hôte de routine, l’image de base a changé et a apporté une configuration IPv6 par défaut plus nftables. Docker a continué de fonctionner, les services ont continué de fonctionner, et personne n’a remarqué. Mais la vérification a signalé un nouveau listener : un endpoint métriques interne était désormais joignable sur [::]:9100.
L’équipe l’a traité comme un défaut, pas une curiosité. Ils l’ont corrigé en liant les métriques au loopback et en ajoutant une règle nftables inet pour bloquer par défaut l’entrée inattendue. Pas d’incident, pas de pager, pas de fenêtre de changement d’urgence.
C’est le genre de prévention qui semble une perte de temps jusqu’à ce que vous voyiez l’alternative. C’est aussi une pratique que vous pouvez justifier auprès des dirigeants sans paraître vendre la peur : « Nous empêchons l’exposition accidentelle avant livraison. »
Schémas de durcissement qui fonctionnent réellement
Pattern A: Explicit binds for every published port
Si vous ne retenez qu’une chose de cet article, retenez ceci : ne publiez jamais sans une adresse de bind explicite.
- Service interne : publiez sur
127.0.0.1(et optionnellement::1si vous avez vraiment besoin du loopback IPv6). - Service public : publiez sur l’adresse de l’interface publique spécifique (v4 et/ou v6), pas le wildcard.
« Mais Docker Compose ne facilite pas cela. » Si — il suffit d’être explicite.
Pattern B: Default-deny inbound on IPv6, then allow what you mean
Si l’hôte a une IPv6 globale, traitez-la comme l’IPv4 publique. Même sérieux, même hygiène. Cela signifie :
- INPUT : drop l’entrée sauf SSH (depuis des plages allowlistées), votre reverse proxy, et ce qui est réellement requis.
- FORWARD : drop par défaut ; autorisez les flux établis et uniquement le forwarding que vous voulez vers les réseaux Docker.
- DOCKER-USER : placez votre politique d’exposition des conteneurs ici afin que les mises à jour Docker n’« annulent » pas votre intention.
Pattern C: Prefer a single policy plane (nftables inet) where possible
Si votre plateforme supporte nftables proprement, une table de famille inet vous donne un jeu de règles qui s’applique aux deux IPv4 et IPv6. Moins de jeux de règles signifie moins de zones oubliées.
Cela ne vous rend pas magiquement sûr. Cela réduit juste le nombre d’endroits où vous pouvez oublier de faire de la sécurité.
Pattern D: If you don’t need Docker IPv6, disable it intentionally
Ce n’est pas une déclaration idéologique. C’est opérationnel. Si vous n’avez pas d’exigence IPv6 pour les conteneurs, vous achetez de la complexité sans bénéfice.
Désactivez au niveau Docker et éventuellement au niveau hôte, selon les besoins de votre environnement. La désactivation au niveau hôte peut avoir des effets secondaires dans les clouds modernes, faites-le en connaissance de cause.
Pattern E: Run public ingress through one choke point
Un reverse proxy ou un ingress controller n’est pas juste « un autre saut ». C’est l’endroit où vous centralisez :
- La politique TLS et la rotation des certificats
- Authentification, limitation de débit, limites de taille de requête
- Des logs d’accès que vous pouvez réellement corréler
- Le comportement explicite de bind v4/v6
La publication directe de ports va pour des services véritablement publics avec une bonne hygiène. C’est un piège pour les services « internes » jamais conçus pour l’extérieur.
Citation (idée paraphrasée) de James Hamilton (Amazon/AWS reliability engineering) : « Tout échoue ; concevez pour que les échecs soient contenus et récupérables. »
Blague #2 : La première règle du club IPv6, c’est qu’on ne parle pas du club IPv6. La deuxième règle, c’est que votre conteneur en parle.
Erreurs courantes : symptôme → cause racine → correction
Voici celles que je vois le plus dans les systèmes réels. Les symptômes sont généralement déroutants parce que les vérifications IPv4 semblent correctes.
1) Symptom: “Our port is blocked by firewall, but scanners still hit it”
Cause racine : Le pare-feu IPv4 est configuré ; l’IPv6 est en allow par défaut (ou l’amont l’autorise). Le service est publié sur [::].
Correction : Ajoutez un filtrage IPv6 équivalent (nftables inet préféré), ou liez explicitement les publications à IPv4 seulement, et vérifiez avec ss -lntp.
2) Symptom: “Only some clients can reach the service; logs don’t match”
Cause racine : Les clients dual-stack privilégient parfois IPv6. Votre pipeline d’observabilité et vos allowlists étaient IPv4-only.
Correction : Assurez-vous que les logs capturent la remote v6, mettez à jour les allowlists pour inclure des plages IPv6, ou désactivez l’exposition IPv6 pour ce service.
3) Symptom: “We disabled iptables rules, but Docker still exposes ports”
Cause racine : Le proxy userland ou des listeners hôtes acceptent encore des connexions ; ou des règles nftables existent malgré la vue iptables.
Correction : Vérifiez les listeners réels avec ss. Inspectez nftables avec nft list ruleset. Ne vous fiez pas à la vue d’un seul outil.
4) Symptom: “Container has a ULA IPv6, but it’s reachable from other networks”
Cause racine : Les ULA peuvent être routées en interne. Elles ne sont pas « privées » au sens NAT ; elles sont « non attribuées globalement ». Le routage interne les rend atteignables.
Correction : Filtrez aux frontières, contraignez les routes, ou évitez d’assigner IPv6 aux conteneurs qui n’en ont pas besoin.
5) Symptom: “We set DOCKER-USER rules; IPv6 still leaks”
Cause racine : Les règles ont été ajoutées pour iptables (IPv4) uniquement, pas ip6tables ; ou les règles ciblent la mauvaise interface ; ou le trafic frappe INPUT (listener hôte) et non FORWARD.
Correction : Miroitez la politique dans ip6tables ou utilisez nftables inet. Confirmez si la socket est au niveau hôte (docker-proxy) vs forwardée.
6) Symptom: “Disabling IPv6 broke package updates / cloud metadata”
Cause racine : Votre environnement attend IPv6 pour certains endpoints, ou DNS retourne AAAA en premier et le comportement du résolveur change.
Correction : Ne désactivez pas IPv6 de façon globale sans tests. Préférez garder IPv6 et mettre en place default-deny inbound et des publications explicites.
Listes de contrôle / plan étape par étape
Étape par étape : Verrouiller un hôte Docker devenu accidentellement dual-stack
-
Inventorier l’exposition.
Exécutezss -lntpetdocker pspour trouver les ports publiés et les listeners v6. -
Décidez de votre position.
Choisissez une des options : pas d’IPv6, IPv6 interne uniquement, ou IPv6 public pour des services spécifiques. -
Corrigez les binds.
Mettez à jour les définitions Compose/services pour publier avec des adresses explicites (loopback pour interne). -
Faites respecter la politique via le pare-feu.
Utilisez nftablesinetsi possible ; sinon, miroir iptables/ip6tables. -
Utilisez DOCKER-USER pour la politique de forwarding des conteneurs.
Default-drop, allowlist des entrées requises. -
Vérifiez depuis l’extérieur.
Testez la reachabilité IPv6 avec curl vers l’IPv6 globale de l’hôte et les ports publiés. -
Rendez cela persistant.
Persistez les règles du pare-feu. Assurez-vous que les redémarrages Docker ne suppriment pas votre politique. Ajoutez une vérification CI qui signale de nouveaux listeners.
Checklist : À quoi ressemble « suffisamment sécurisé » pour la plupart des équipes
- Chaque port publié spécifie une adresse de bind (pas de wildcard implicite).
- La politique du pare-feu est default-deny pour l’entrée IPv6 (et IPv4) avec des autorisations explicites.
- La chaîne DOCKER-USER existe et contient l’intention de votre organisation (v4 et v6).
- Au moins un scan/examen externe teste la reachabilité IPv6, pas seulement l’IPv4.
- Les logs incluent les adresses IPv6 distantes et votre alerting ne les supprime pas au parsing.
- Les security groups / NACLs cloud incluent des règles IPv6, examinées comme l’IPv4.
Checklist : Si vous voulez vraiment « pas d’IPv6 ici »
- Daemon Docker
"ipv6": falsesauf si requis. - Le pare-feu hôte bloque inbound IPv6 quoi qu’il arrive (défense en profondeur).
- La configuration réseau cloud n’assigne pas d’IPv6 globale à l’instance sauf besoin.
- Le monitoring continue à fonctionner (certains agents préfèrent IPv6).
FAQ
1) Is this a Docker bug?
Généralement non. C’est Docker qui fait ce pour quoi il a été conçu : rendre le réseau fonctionnel avec un minimum d’entrée opérateur. Le « bug » est de supposer que l’entrée minimale équivaut à une exposition minimale.
2) If my container only listens on IPv4, can it still be reachable over IPv6?
Oui, selon la façon dont l’hôte publie le port et si un proxy traduit/accepte en v6 et forwarde en local vers v4. Confirmez avec ss sur l’hôte et des tests de connexion réels.
3) Is binding to 127.0.0.1 enough?
Pour l’exposition IPv4, oui. Pour l’IPv6, vous devez aussi vous assurer de ne pas publier sur [::]. Dans Compose, soyez explicite ; ne comptez pas sur les valeurs par défaut. Vérifiez avec docker ps et docker port.
4) Should I disable IPv6 everywhere to be safe?
Si vous n’en avez pas besoin, la désactivation peut réduire le risque. Mais une désactivation globale au niveau hôte peut casser des hypothèses réseau cloud et le dépannage. Un défaut plus résilient : garder IPv6 activé, default-deny inbound, et publier explicitement.
5) Why does my IPv6 firewall look empty even though I set rules?
Vous regardez peut-être ip6tables alors que le système utilise en réalité nftables, ou vice versa. Vérifiez toujours les listeners (ss) et inspectez les jeux de règles nftables si vous êtes sur iptables-nft.
6) What’s the single best place to enforce container ingress policy?
La chaîne DOCKER-USER (ou équivalent dans nftables) est le meilleur crochet pour « avant que Docker ne fasse son travail ». Elle est conçue pour la politique opérateur.
7) Does Kubernetes have the same problem?
Mécaniques différentes, même classe de défaillance. NodePorts, pods hostNetwork et le support IPv6 du CNI peuvent exposer des services via IPv6 si le pare-feu du nœud et les règles cloud ne sont pas symétriques.
8) How do I prove to myself that I fixed it?
Trois preuves : (1) ss -lntp n’affiche pas de listeners [::] inattendus, (2) la politique du pare-feu pour IPv6 est explicite (default-deny ou allowlists strictes), et (3) un test de connexion IPv6 externe échoue pour les ports qui devraient être privés.
9) What about “ipv6″: true in Docker—does that automatically make things public?
Pas automatiquement. Activer IPv6 donne aux conteneurs des adresses v6 sur les réseaux configurés et peut établir des règles de forwarding. L’exposition publique dépend toujours du routage et du filtrage. Mais cela augmente les façons d’être surpris, donc associez-le à une politique explicite.
Conclusion : prochaines étapes que vous pouvez exécuter
Les fuites IPv6 Docker ne sont pas mystérieuses. Elles résultent de façon prévisible d’hôtes dual-stack, de publications implicites, et de politiques de pare-feu qui ne parlent que l’IPv4. Vous ne les corrigez pas par l’espoir. Vous les corrigez par des binds explicites et des règles default-deny qui couvrent les deux familles de protocoles.
Faites ceci ensuite, dans cet ordre :
- Exécutez
ip -6 addr,ss -lntpetdocker pspour identifier l’exposition réelle. - Décidez si IPv6 est requis sur cet hôte et dans ces conteneurs.
- Rendez la publication explicite (bind d’adresses) et centralisez l’ingress public quand possible.
- Appliquez la politique dans
DOCKER-USER(IPv4 et IPv6) ou nftables inet avec default-deny. - Vérifiez depuis l’extérieur, et ajoutez un contrôle automatisé pour éviter une régression silencieuse le trimestre suivant.