Vous verrouillez un hôte avec UFW, n’ouvrez que SSH, déployez quelques conteneurs, puis rentrez chez vous. Puis un scanner (ou pire, un client) trouve votre service à l’écoute sur l’internet public malgré tout. Rien ne fait moins confiance à un pare-feu qu’un pare-feu qui fait techniquement ce qu’on lui a demandé — simplement pas ce que vous aviez voulu.
Sur Ubuntu 24.04, Docker peut encore percer des trous autour d’UFW d’une manière qui surprend même des opérateurs compétents. Ce n’est pas un réquisitoire « Docker est insecure ». Il s’agit de comprendre le flux des paquets, la couche d’interface iptables/nftables et les crochets spécifiques que Docker utilise — puis de placer les bons contrôles pour que les conteneurs continuent de fonctionner et que les ports cessent d’apparaître sur Internet.
Ce qui se passe réellement : pourquoi UFW « fonctionne » et que des ports restent ouverts
UFW n’est pas un pare-feu. UFW est une bibliothécaire aimable qui classe les règles de pare-feu dans les bons tiroirs. Le videur réel à la porte est netfilter (iptables/nftables). Docker, pendant ce temps, est le responsable VIP qui s’approche du videur et ajoute quelques notes « laisse entrer ces gens » — souvent dans un tiroir que UFW ne surveille pas.
Lorsque vous publiez un port avec Docker (-p 0.0.0.0:8080:80 ou un stanza ports: dans Compose), Docker installe des règles NAT et filter pour que le trafic arrivant sur l’interface publique de l’hôte à ce port soit DNATé vers l’IP du conteneur. Ces règles sont insérées dans des chaînes comme PREROUTING (table nat) et FORWARD (table filter), et Docker gère aussi ses propres chaînes telles que DOCKER, DOCKER-ISOLATION-STAGE-*, et surtout DOCKER-USER.
Où se situe UFW ? UFW gère généralement des règles dans des chaînes comme ufw-before-input, ufw-user-input, ufw-before-forward, etc. Il peut bloquer le trafic local à l’hôte vers des services liés à l’hôte, mais les ports publiés des conteneurs traversent souvent le chemin FORWARD après DNAT, et Docker les a déjà autorisés. Ainsi UFW peut afficher « deny 8080/tcp » toute la journée et regarder les paquets être transférés vers un conteneur parce que ce refus est appliqué dans une chaîne/ordre différent de celui que vous imaginiez.
Ubuntu 24.04 ajoute un autre niveau de confusion pour l’opérateur : les distributions modernes utilisent de plus en plus nftables comme moteur sous-jacent, tout en exposant une interface compatible iptables. Docker programme typiquement des règles iptables (via iptables-nft sur Ubuntu), qui apparaissent dans le jeu de règles nft. UFW programme aussi des règles, et l’interaction revient à « qui est évalué en premier » plutôt qu’à « qui a raison ». Les pare-feux sont déterministes ; les hypothèses des opérateurs ne le sont pas.
Si vous retenez une règle : lorsque Docker publie un port, considérez-le comme ouvrir un port dans votre pare-feu, car c’est effectivement ce que c’est — simplement pas au même endroit où vous regardiez.
Petite blague n°1 : Les pare-feux sont comme les portes de bureau — n’importe qui peut entrer si la personne avec les droits admin continue de les caler ouvertes « pour faire vite ».
Faits intéressants et un peu d’histoire (pour comprendre le comportement)
- Docker a choisi iptables tôt parce que c’était universel. Au milieu des années 2010, le réseau Linux était fragmenté ; iptables était le moindre mal commun pour le NAT et le forwarding.
- UFW est avant tout un générateur de règles iptables. C’est un outil de politique, pas un filtre de paquets runtime. Il écrit des règles ; il ne « possède » pas netfilter.
- Les ports publiés utilisent DNAT, pas seulement des sockets à l’écoute. C’est pourquoi
ss -lntppeut montrer docker-proxy ou un port lié, mais la vraie magie est NAT + forwarding. - Docker utilisait historiquement un proxy en espace utilisateur pour la publication de ports. Les versions récentes préfèrent le NAT noyau quand c’est possible, mais le comportement varie selon la version et les réglages ; cela change ce que vous voyez dans
ss. - La chaîne DOCKER-USER existe spécifiquement pour que vous puissiez outrepasser Docker. Docker l’a ajoutée après des années pendant lesquelles les opérateurs demandaient un point d’accroche stable que Docker ne réécrirait pas.
- Le « deny » d’UFW ne s’applique pas automatiquement au trafic transféré. Les valeurs par défaut d’UFW sont souvent orientées INPUT (vers l’hôte), pas FORWARD (à travers l’hôte vers les conteneurs).
- L’iptables d’Ubuntu est souvent implémenté sur le backend nft. Beaucoup d’opérateurs pensent encore en termes iptables ; sous le capot, nft exécute les règles (et les priorités comptent).
- Les security groups cloud peuvent masquer le problème. Dans des VPC stricts, le pare-feu de l’hôte peut être redondant ; déplacez le même hôte en local et la surprise devient un incident.
- Docker rootless change la donne. Avec le mode rootless, le chemin d’exposition des ports et de filtrage peut différer sensiblement ; on ne peut pas appliquer aveuglément la même recette iptables.
Rien de tout cela n’est triviaux obscurs. C’est pourquoi l’argument « mais UFW est activé » s’effondre lors d’un post-mortem.
Une idée paraphrasée : Dr Richard Cook (ingénierie de la résilience) a une idée bien connue : les défaillances arrivent quand le travail normal et la complexité se rencontrent, pas parce que les gens sont négligents.
Playbook de diagnostic rapide (vérifier premier/deuxième/troisième)
Quand quelqu’un dit « UFW est activé mais le port est ouvert », ne débattez pas. Ne devinez pas. Exécutez un playbook court et reproductible et décidez d’après des preuves.
Premier : confirmer ce qui est réellement exposé
- Depuis une autre machine : scannez l’IP publique de l’hôte pour le(s) port(s) signalé(s).
- Sur l’hôte : vérifiez les sockets à l’écoute et les ports publiés par Docker.
- Décision : s’agit-il d’un processus hôte, d’une publication de conteneur ou d’un load balancer/NAT en amont ?
Deuxième : cartographier le chemin d’exposition
- Trouvez le conteneur et la correspondance des ports publiés.
- Vérifiez si le trafic est INPUT (hôte) ou FORWARD (vers le conteneur via DNAT).
- Décision : devez-vous bloquer dans DOCKER-USER, ajuster la politique de forwarding d’UFW, ou changer les bindings de publication Docker ?
Troisième : inspecter l’ordre des règles, pas seulement leur présence
- Listez les règles iptables/nft avec numéros de ligne/counters.
- Recherchez les règles ACCEPT de Docker précédant les DROP d’UFW dans la chaîne concernée.
- Décision : placez l’application dans DOCKER-USER (préféré), ou restructurez explicitement la gestion du forward d’UFW.
Quatrième : appliquez un correctif minimal, puis retestez depuis l’extérieur
- Commencez par « deny par défaut pour les ports publiés » dans DOCKER-USER, puis autorisez uniquement ce dont vous avez besoin.
- Relancez le scan externe et confirmez que les vérifications de santé des conteneurs passent toujours.
- Décision : si le trafic de production casse, revenez en arrière et passez à « lier les ports publiés à des IPs de confiance » comme intermédiaire plus sûr.
Tâches pratiques : commandes, sorties et décisions à prendre
Voici les tâches que j’exécute réellement lors d’un triage et d’un renforcement. Chacune contient : une commande, une sortie réaliste, ce que cela signifie, et la décision que vous prenez.
Tâche 1 : Confirmer l’état d’UFW et la politique de base
cr0x@server:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
Ce que cela signifie : UFW est activé, l’entrée par défaut est deny, mais le routage (forwarded) est désactivé. Le trafic publié par Docker circule couramment via FORWARD, pas INPUT.
Décision : Ne supposez pas que « deny incoming » couvre les conteneurs. Vous devez vérifier le comportement de forwarding et les règles Docker.
Tâche 2 : Vérifier quels ports sont à l’écoute sur l’hôte
cr0x@server:~$ sudo 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=1186,fd=3))
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=4123,fd=4))
LISTEN 0 4096 127.0.0.1:9090 0.0.0.0:* users:(("prometheus",pid=2201,fd=7))
Ce que cela signifie : Le port 8080 est lié sur toutes les interfaces via docker-proxy. C’est un indice fort que l’exposition est liée au conteneur, pas à un démon hôte aléatoire.
Décision : Identifiez quel conteneur a publié 8080 et s’il doit être public.
Tâche 3 : Lister clairement les ports publiés par Docker
cr0x@server:~$ sudo docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"
NAMES IMAGE PORTS
webapp ghcr.io/acme/web:1.7 0.0.0.0:8080->80/tcp
redis redis:7 6379/tcp
metrics-gw prom/pushgateway 0.0.0.0:9091->9091/tcp
Ce que cela signifie : webapp et metrics-gw sont publiés publiquement. Redis ne l’est pas (exposition limitée au conteneur).
Décision : Si ces services doivent être privés, corrigez les bindings et/ou appliquez une politique de pare-feu.
Tâche 4 : Inspecter les paramètres réseau d’un conteneur
cr0x@server:~$ sudo docker inspect webapp --format '{{json .NetworkSettings.Ports}}'
{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}
Ce que cela signifie : Publié explicitement sur toutes les interfaces. C’est le « port ouvert par surprise » en une ligne JSON.
Décision : Soit lier à une IP spécifique (par ex. 127.0.0.1), soit imposer une politique dans DOCKER-USER pour contrôler l’exposition.
Tâche 5 : Identifier l’interface publique de l’hôte et les IP
cr0x@server:~$ ip -br addr
lo UNKNOWN 127.0.0.1/8 ::1/128
ens3 UP 203.0.113.10/24 fe80::5054:ff:fe12:3456/64
docker0 DOWN 172.17.0.1/16
Ce que cela signifie : L’IP publique est sur ens3. Le bridge Docker est docker0. Connaître les interfaces importe pour des règles ciblées.
Décision : Si vous ne voulez qu’un accès local, liez les ports à 127.0.0.1 ou à une interface privée, pas à 0.0.0.0.
Tâche 6 : Vérifier la politique de routage d’UFW et le forwarding du noyau
cr0x@server:~$ sudo sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1
Ce que cela signifie : Le forwarding est activé (Docker l’active typiquement). Le « routed disabled » d’UFW n’empêche pas le noyau de transférer si les règles le permettent.
Décision : Considérez le filtrage FORWARD comme obligatoire sur les hôtes conteneurs.
Tâche 7 : Regarder l’ordre de la chaîne FORWARD d’iptables (où se trouve la vérité)
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
Ce que cela signifie : Docker insère DOCKER-USER en tête. C’est votre levier de contrôle. Si vous ne l’utilisez pas, les propres règles ACCEPT de Docker décideront.
Décision : Mettez une politique restrictive dans DOCKER-USER, pas des règles INPUT dispersées d’UFW.
Tâche 8 : Voir la chaîne DOCKER-USER (souvent vide par défaut)
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN
Ce que cela signifie : Aucune restriction n’existe ; tout passe vers les règles Docker après RETURN.
Décision : Ajoutez des allow/deny explicites ici pour contrôler l’exposition des ports publiés.
Tâche 9 : Inspecter les règles NAT qui effectuent le transfert de port
cr0x@server:~$ sudo iptables -t nat -S DOCKER | sed -n '1,8p'
-N DOCKER
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 9091 -j DNAT --to-destination 172.17.0.3:9091
Ce que cela signifie : Le trafic vers le port hôte 8080 est DNATé vers l’IP du conteneur. C’est pourquoi « fermer le port » doit aussi se faire dans le chemin de forwarding.
Décision : Ne luttez pas contre le NAT avec des DROP en INPUT. Contrôlez le forwarding via DOCKER-USER (ou changez le binding de publication).
Tâche 10 : Vérifier depuis l’extérieur (parce que les vérifications locales trompent)
cr0x@server:~$ nc -vz 203.0.113.10 8080
Connection to 203.0.113.10 8080 port [tcp/http-alt] succeeded!
Ce que cela signifie : Le port est joignable depuis le réseau. Si vous exécutez cela depuis le même hôte, vous pouvez obtenir une confiance erronée via le routage loopback.
Décision : Considérez la validation externe comme obligatoire, pas optionnelle.
Tâche 11 : Appliquer une politique « deny par défaut » pour le forwarding des conteneurs dans DOCKER-USER
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i ens3 -o docker0 -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i ens3 -o docker0 -j DROP
-A DOCKER-USER -j RETURN
Ce que cela signifie : Le trafic provenant de l’interface publique et se dirigeant vers docker0 est rejeté avant que les règles d’autorisation de Docker ne s’exécutent.
Décision : Si vous voulez « rien de publié n’est joignable depuis Internet sauf autorisation explicite », c’est la posture par défaut appropriée.
Tâche 12 : Ajouter une exception d’autorisation spécifique (seulement ce que vous voulez exposer)
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i ens3 -o docker0 -p tcp --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -L DOCKER-USER -n --line-numbers
Chain DOCKER-USER (1 references)
num target prot opt source destination
1 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80
2 DROP all -- 0.0.0.0/0 0.0.0.0/0
3 RETURN all -- 0.0.0.0/0 0.0.0.0/0
Ce que cela signifie : Vous autorisez le trafic transféré vers le port de destination 80 (port côté conteneur après DNAT) tout en rejetant tout le reste venant d’ens3 vers docker0.
Décision : Maintenez cette liste d’autorisation intentionnellement. Si vous ne pouvez pas expliquer chaque port autorisé en une phrase, il ne devrait pas être autorisé.
Tâche 13 : Faire en sorte qu’UFW joue mieux avec le forwarding (seulement si vous insistez sur une politique centrée UFW)
cr0x@server:~$ sudo grep -n '^DEFAULT_FORWARD_POLICY' /etc/default/ufw
19:DEFAULT_FORWARD_POLICY="DROP"
Ce que cela signifie : UFW est configuré pour dropper par défaut le trafic transféré (bon), mais Docker peut avoir des règles qui autorisent malgré tout des forwards spécifiques.
Décision : Gardez-le sur DROP. S’il est sur ACCEPT, remettez-le à DROP sauf si vous aimez les surprises d’exposition.
Tâche 14 : Vérifier si UFW gère la chaîne DOCKER-USER (généralement non)
cr0x@server:~$ sudo iptables -S | grep -E 'ufw|DOCKER-USER' | sed -n '1,12p'
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-N ufw-before-forward
-N ufw-user-forward
Ce que cela signifie : UFW et Docker coexistent, mais UFW n’insère pas de politique dans DOCKER-USER par défaut. C’est pourquoi « ufw deny 8080 » n’a pas aidé.
Décision : Décidez qui possède la politique de forwarding des conteneurs. Mon choix : DOCKER-USER, géré via la gestion de configuration, pas par des modifications manuelles.
Tâche 15 : Persister les changements iptables après redémarrage (parce que les reboots arrivent à 3h du matin)
cr0x@server:~$ sudo apt-get update
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Reading package lists... Done
cr0x@server:~$ sudo apt-get install -y iptables-persistent
Setting up iptables-persistent (1.0.20) ...
Saving current rules to /etc/iptables/rules.v4...
Saving current rules to /etc/iptables/rules.v6...
Ce que cela signifie : Vos règles iptables actuelles ont été sauvegardées sur disque et seront restaurées au démarrage.
Décision : Si vous comptez sur les règles DOCKER-USER, persistez-les. Sinon, le « correctif » disparaîtra après une maintenance.
Tâche 16 : Retester l’exposition après application de la politique DOCKER-USER
cr0x@server:~$ nc -vz 203.0.113.10 8080
nc: connect to 203.0.113.10 port 8080 (tcp) failed: Connection timed out
Ce que cela signifie : Le port n’est plus accessible depuis l’extérieur (timeout typique pour un drop). C’est le résultat attendu.
Décision : Vérifiez que les services publics requis fonctionnent toujours, puis documentez la politique pour que le prochain déploiement ne la « corrige » pas involontairement.
Stratégies de correction qui colmatent la brèche sans casser les conteneurs
Vous avez trois stratégies sensées. Choisissez-en une délibérément. Les mélanger de façon ad hoc est la façon dont vous vous retrouvez avec des règles qui ne fonctionnent que quand la lune est dans la bonne phase.
Stratégie A (recommandée) : Utiliser DOCKER-USER comme point d’application
Docker promet de ne pas gérer vos règles DOCKER-USER. C’est tout l’intérêt de la chaîne. Si vous voulez « Docker peut faire ses choses Docker, mais la politique de sécurité reste la mienne », DOCKER-USER est l’endroit pour l’affirmer.
Modèle : drop par défaut du trafic des interface(s) publiques vers le bridge docker0 ; ajoutez des règles d’autorisation pour des ports de destination spécifiques ou des CIDR sources ; laissez l’est-ouest interne intact.
Avantages : Stable, explicite, survit au churn des conteneurs, ne dépend pas de la vision d’UFW du forwarding. Fonctionne avec Compose, schémas Swarm-ish et le réseau bridge classique.
Inconvénients : Un autre endroit pour gérer la politique. Vous devez persister les règles et vous assurer que la gestion de configuration en est propriétaire.
Stratégie B : Ne plus publier sur 0.0.0.0 (lier à des IPs spécifiques)
Si un service doit seulement être accédé localement ou via un reverse proxy, ne le publiez pas de façon large.
Dans Compose, au lieu de :
cr0x@server:~$ cat compose.yaml | sed -n '1,20p'
services:
webapp:
image: ghcr.io/acme/web:1.7
ports:
- "8080:80"
Utilisez des bindings explicites :
cr0x@server:~$ cat compose.yaml | sed -n '1,20p'
services:
webapp:
image: ghcr.io/acme/web:1.7
ports:
- "127.0.0.1:8080:80"
Avantages : Modèle mental simple. Aucun port n’est accessible depuis l’extérieur sauf si vous le dites. Idéal quand vous positionnez un reverse proxy (Nginx/Traefik/Caddy) sur l’hôte.
Inconvénients : Facile à régresser (« retire juste 127.0.0.1 pour un test »), et cela n’aide pas si vous avez besoin d’un accès externe uniquement depuis certains réseaux.
Stratégie C : Faire gérer le routage par UFW explicitement (avancé, fragile)
Vous pouvez forcer plus de politique dans les chaînes de forwarding d’UFW et vous reposer sur des règles de route UFW. Cela peut fonctionner, mais vous luttez contre le fait que Docker a sa propre vision et met à jour des règles quand les conteneurs démarrent/s’arrêtent.
Si vous empruntez cette voie, vous devez :
- Garder la politique de forward UFW sur DROP
- Utiliser des règles
ufw routeintentionnellement - Auditer l’insertion des règles Docker après chaque mise à jour de Docker
Personnellement, je préfère DOCKER-USER car il est conçu pour exactement cela. UFW est excellent pour les « services hôtes » et la politique globale ; il n’est pas idéal comme source unique de vérité pour le forwarding des conteneurs sauf si vous aimez déboguer le week-end.
Petite blague n°2 : NAT est l’équivalent réseau du tableur partagé — tout le monde en dépend, personne ne lui fait confiance, et il fait toujours quelque chose que vous n’avez pas autorisé.
Trois mini-récits d’entreprise depuis le terrain
Mini-récit n°1 : L’incident causé par une mauvaise hypothèse
L’entreprise avait une checklist de durcissement standard : activer UFW, autoriser SSH depuis le VPN, refuser tout le reste. Les équipes étaient encouragées à utiliser des conteneurs pour des outils internes, principalement pour faciliter les mises à jour. Un ingénieur plateforme a intégré UFW dans l’image de base et l’a appelé « garde-fous ».
Une équipe produit a déployé un nouveau tableau de bord interne en conteneur et l’a publié sur -p 8080:80 pour pouvoir le vérifier rapidement. Ils ont supposé « UFW va le bloquer depuis Internet ». Ce ne fut pas le cas. Le service était joignable depuis n’importe où, et en moins d’un jour quelqu’un en dehors de l’entreprise le sondait.
Le post-mortem fut inconfortable parce que personne n’avait fait quoi que ce soit d’extravagant. L’ingénieur n’était pas négligent ; il avait simplement projeté un modèle mental de pare-feu hôte sur le forwarding des conteneurs. Le SRE de garde l’a reproduit immédiatement : UFW refusait 8080/tcp sur INPUT, mais le trafic n’avait pas besoin d’INPUT après DNAT.
Le correctif fut deux lignes dans DOCKER-USER plus une règle dans leurs conventions Compose : « pas de ports publiés sans binding IP explicite ». Le correctif culturel fut meilleur : ils ont mis à jour la checklist pour inclure un scan externe et un audit d’ordre de règles lors de toute installation de Docker.
Mini-récit n°2 : L’optimisation qui s’est retournée contre eux
Une autre organisation avait des problèmes de performance sur un hôte edge chargé. Quelqu’un a décidé de « simplifier le réseau » en désactivant agressivement des composants qu’il jugeait redondants. Ils ont réduit le logging du pare-feu, supprimé des chaînes qu’ils ne reconnaissaient pas, et ont essayé de faire d’UFW le seul outil en jeu.
Ça a fonctionné — jusqu’à ce qu’une mise à jour de Docker réintroduise ses chaînes et réordonne des parties du chemin FORWARD. Soudain, un service censé être joignable uniquement depuis un sous-réseau interne devint accessible depuis des réseaux plus larges. L’opérateur jurait que rien n’avait changé « dans le pare-feu », ce qui était techniquement vrai : la config UFW n’avait pas changé. Ce sont les règles de Docker qui avaient changé.
Le vrai retour de bâton n’était pas seulement une exposition. Le débogage devint plus difficile parce que leur « optimisation » avait supprimé les indices : les compteurs étaient réinitialisés, les logs plus silencieux, et il n’y avait plus d’endroit établi pour mettre une politique que Docker n’écraserait pas. Ils ont dû réapprendre le flux des règles sous pression.
Ils se sont remis en faisant la chose ennuyeuse qu’ils avaient essayé d’éviter : définir un document de politique de pare-feu hôte unique, l’appliquer via DOCKER-USER, et garder UFW pour les services locaux. L’impact sur la performance fut négligeable comparé au temps perdu en réponse à l’incident.
Mini-récit n°3 : La pratique ennuyeuse mais correcte qui a sauvé la mise
Une équipe de services financiers exploitait des hôtes conteneurs avec un processus de changement strict. Ce n’était pas glamour. Chaque hôte avait un petit script « invariants réseau » qui s’exécutait en CI puis de nouveau sur l’hôte après le déploiement : lister les ports publiés, diff des chaînes iptables, exécuter un test de connectivité externe depuis un sous-réseau scanner contrôlé.
Un vendredi après-midi, un développeur a mis à jour un fichier Compose et a accidentellement changé un mapping de port de 127.0.0.1:9000:9000 en 9000:9000. Sur son laptop c’était plus simple. En production cela aurait exposé une console d’administration.
Le test des invariants a échoué en CI parce que leur scanner a pu atteindre le port 9000 sur l’hôte de staging. Le pipeline a interrompu le déploiement. Personne n’a dû être un héros, et personne n’a dû prétendre « je l’aurais remarqué en revue ». Le script l’a attrapé parce qu’il était conçu pour attraper exactement cette classe d’erreur.
Ils ont corrigé le binding Compose, relancé le test, et déployé. Ce n’était pas excitant. C’est tout l’intérêt.
Erreurs fréquentes : symptôme → cause racine → correctif
1) « UFW refuse 8080 mais il reste joignable »
Symptôme : ufw status n’affiche pas de règle allow, pourtant les scans externes se connectent.
Cause racine : Le trafic est DNATé et transféré vers un conteneur ; les règles INPUT d’UFW n’arrêtent pas le trafic FORWARD que Docker a autorisé.
Correctif : Appliquez la politique dans DOCKER-USER (drop public→docker0) puis autorisez seulement les ports nécessaires ; ou liez les ports à 127.0.0.1.
2) « J’ai droppé le trafic dans DOCKER-USER et maintenant tout a cassé »
Symptôme : Les conteneurs ne peuvent plus atteindre Internet, ou des services internes ne communiquent plus.
Cause racine : Règle DOCKER-USER trop large qui a droppé tout le forwarding, pas seulement l’ingress publique vers docker0. Erreur courante : drop sans cibler les interfaces.
Correctif : Ciblez les règles : -i ens3 -o docker0 pour l’ingress vers les conteneurs, et laissez -i docker0 -o ens3 (egress) intact à moins d’en avoir vraiment besoin.
3) « Après le reboot, les ports sont de nouveau ouverts »
Symptôme : Vous l’avez corrigé hier ; aujourd’hui c’est revenu.
Cause racine : Les règles DOCKER-USER ont été ajoutées en interactif et non persistées ; Docker a ensuite recréé ses propres règles au démarrage.
Correctif : Persistez les règles avec iptables-persistent ou gérez-les via un unit systemd/gestion de configuration qui s’exécute après le démarrage de Docker.
4) « Seulement certains ports publiés sont bloqués ; d’autres passent »
Symptôme : Le port 8080 est bloqué, mais 9091 reste accessible.
Cause racine : Votre allowlist/denylist est basée sur les ports hôtes, mais votre correspondance DOCKER-USER porte sur le port de destination post-DNAT (port du conteneur). Ou vice-versa.
Correctif : Décidez sur quoi vous faites la correspondance. Dans DOCKER-USER, matcher --dport se réfère souvent au port du conteneur après DNAT sur le chemin FORWARD. Vérifiez avec des compteurs et testez chaque port.
5) « Le allow route d’UFW n’a rien fait »
Symptôme : Vous ajoutez une règle de route UFW, mais la connectivité ne change pas.
Cause racine : Ordre des règles : les accept FORWARD de Docker peuvent toujours autoriser le trafic avant les chaînes de route d’UFW, selon comment vos règles sont insérées et quelles chaînes sont atteintes.
Correctif : Préférez DOCKER-USER pour le contrôle d’ingress des conteneurs. Si vous devez utiliser le routage UFW, confirmez l’ordre des chaînes avec iptables -S FORWARD et regardez les compteurs.
6) « C’est fermé depuis l’extérieur, mais la surveillance interne dit que c’est ouvert »
Symptôme : Les checks internes de santé réussissent ; les contrôles externes échouent ; quelqu’un parle de faux positif.
Cause racine : Chemin différent : les vérifications internes peuvent provenir d’une interface privée, d’un VPN, ou du loopback, pas de l’interface publique que vous filtrez.
Correctif : Validez depuis la même perspective réseau que votre modèle de menace (internet/limite VPC). Écrivez des règles qui autorisent explicitement les sources internes/VPN et droppent les sources publiques.
Listes de contrôle / plan étape par étape
Plan de durcissement pas-à-pas (faire ceci sur chaque hôte Docker)
- Inventaire de l’exposition : listez les ports publiés et leurs propriétaires (
docker ps,ss). - Décidez la politique : quels services sont publics, quels services sont privés, et lesquels doivent être uniquement VPN.
- Définissez une posture par défaut : drop par défaut du trafic interface publique → bridge docker dans DOCKER-USER.
- Ajoutez des autorisations explicites : seulement pour les services destinés à être joignables publiquement (ou depuis des CIDR spécifiques).
- Liez les services privés : changez Compose/Docker run pour publier sur
127.0.0.1ou une IP privée. - Persistez les règles : assurez-vous que les règles DOCKER-USER survivent aux redémarrages et aux relances de Docker.
- Retestez depuis l’extérieur : lancez un contrôle de ports depuis l’extérieur de votre réseau hôte.
- Écrivez des invariants : ajoutez des checks CI qui échouent si un fichier Compose publie sur
0.0.0.0sans approbation. - Opérationnalisez les audits : scan périodique + diff des règles de pare-feu et des ports publiés.
Recette minimale « secure-by-default » pour DOCKER-USER
Voici la base que j’aime pour des hôtes exposés à Internet utilisant le réseau bridge Docker. Ajustez les noms d’interface et les ports.
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i ens3 -o docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 2 -i ens3 -o docker0 -p tcp --dport 443 -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 3 -i ens3 -o docker0 -p tcp --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -I DOCKER-USER 4 -i ens3 -o docker0 -j DROP
cr0x@server:~$ sudo iptables -L DOCKER-USER -n --line-numbers
Chain DOCKER-USER (1 references)
num target prot opt source destination
1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
2 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:443
3 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:80
4 DROP all -- 0.0.0.0/0 0.0.0.0/0
5 RETURN all -- 0.0.0.0/0 0.0.0.0/0
Ce que cela fait : Autorise les flux établis et ne forwarde les nouvelles connexions publiques vers les conteneurs que sur 80/443. Tout le reste provenant de l’interface publique vers docker0 est droppé.
Ce que cela ne fait pas : Cela ne sécurise pas vos conteneurs en interne, ne remplace pas TLS, et ne corrige pas l’authentification applicative. Cela empêche simplement que des expositions accidentelles deviennent des politiques.
FAQ
1) Pourquoi Docker « contourne » UFW ?
Il ne contourne pas tant que ça, il utilise un chemin de paquets différent. Les ports publiés des conteneurs utilisent typiquement le NAT et la chaîne FORWARD ; les règles UFW que vous définissez ciblent souvent INPUT. Chaînes différentes, résultats différents.
2) Ceci est-il spécifique à Ubuntu 24.04 ?
Non, mais le backend nftables courant d’Ubuntu 24.04 et les paramètres modernes rendent l’interaction plus facile à mal interpréter. Le comportement de base existe partout où Docker gère des règles iptables.
3) Dois-je désactiver la gestion iptables de Docker ?
Généralement non. Désactiver l’intégration iptables de Docker peut casser le réseau et la publication de ports à moins que vous ne remplaciez entièrement ses règles vous-même. Si vous vous posez la question, vous ne voulez probablement pas cette charge de maintenance.
4) Quel est le correctif rapide le plus sûr pendant un incident ?
Ajoutez un DROP ciblé dans DOCKER-USER pour interface publique → docker0, puis ajoutez des ACCEPT explicites pour les ports que vous devez garder publics. Retestez depuis l’extérieur immédiatement.
5) Si je lie 127.0.0.1:8080:80, est-ce suffisant ?
C’est un contrôle solide pour un accès « host-only », surtout lorsqu’un reverse proxy termine le trafic externe. Mais cela n’aide pas si vous devez rendre le service joignable depuis un sous-réseau privé/VPN sans proxy — dans ce cas vous voudrez des règles DOCKER-USER autorisant des CIDR sources.
6) Est-ce que ça affecte aussi IPv6 ?
Oui, et les gens l’oublient. Si IPv6 est activé et que Docker publie sur IPv6, vous avez besoin d’une politique équivalente dans ip6tables/nft pour v6. Ne supposez pas que les règles v4 couvrent l’exposition v6.
7) Pourquoi ne pas se reposer uniquement sur les security groups cloud ?
Les security groups sont excellents, mais ils ne sont pas toujours présents (on-prem), et ils ne vous protègent pas des mouvements latéraux internes de la même façon. Aussi : les opérateurs déplacent régulièrement des workloads entre environnements. La politique hôte doit être correcte par elle-même.
8) Comment éviter les régressions quand les équipes modifient des fichiers Compose ?
Appliquez des conventions : pas de "8080:80" nu pour les services internes ; exigez un binding IP explicite ou une étiquette de revue. Ajoutez une CI qui parse Compose et échoue lorsqu’un port est publié sur 0.0.0.0 de manière inattendue.
9) Les règles DOCKER-USER vont-elles casser le trafic entre conteneurs ?
Pas si vous les ciblez correctement. Concentrez-vous sur l’ingress depuis l’interface publique vers docker0. Laissez le trafic émanant de docker0 tel quel à moins d’avoir une exigence d’egress spécifique.
Conclusion : prochaines étapes pratiques
Si vous exécutez Docker sur Ubuntu 24.04 et que vous supposez qu’UFW seul contrôle l’exposition, vous avez construit un piège pour votre futur vous. Le correctif n’est pas dramatique : comprenez que les ports publiés des conteneurs vivent dans le chemin forwarding/NAT, puis appliquez la politique là où Docker vous donne un point d’accroche stable — DOCKER-USER.
Prochaines étapes qui ne vous feront pas perdre de temps :
- Exécutez les tâches d’inventaire :
ss,docker ps, et un test de connectivité externe. - Ajoutez un drop par défaut pour interface publique → docker0 dans DOCKER-USER, puis autorisez uniquement ce qui doit être public.
- Changez les services internes pour lier les ports publiés à
127.0.0.1(ou une IP privée) afin qu’un accident n’entraîne pas une exposition. - Persistez vos règles et ajoutez un test de régression en CI. Ennuyeux. Correct. Efficace.
Vous n’essayez pas de « gagner » contre Docker. Vous essayez de rendre votre intention non ambiguë pour le filtre de paquets. Voilà tout le jeu.