Docker + UFW : Pourquoi vos ports sont quand même ouverts — sécurisez correctement

Cet article vous a aidé ?

Vous avez activé UFW. Vous avez défini « deny incoming ». Vous avez même ressenti un petit élan de vertu.
Puis un scan rapide montre que le port de votre conteneur est toujours accessible depuis Internet. Super.

Ce n’est pas qu’UFW soit « mauvais » ni que Docker soit « insecure by default » de façon caricaturale.
C’est une interaction prévisible entre l’automatisation iptables de Docker et la manière dont UFW place ses règles.
Si vous exploitez des systèmes en production, vous devez comprendre le cheminement des paquets, puis appliquer la politique à l’endroit unique où Docker ne peut pas « contourner utilement ».

Le modèle mental : où vos paquets vont réellement

Quand vous publiez un port avec Docker (-p 8080:80), Docker ne demande pas poliment la permission à UFW.
Il programme le filtrage de paquets du noyau (iptables/nftables) pour faire du DNAT et accepter le trafic, parce que « faire fonctionner » l’emporte sur « attendre les humains » dans le design par défaut.

UFW, pendant ce temps, est un gestionnaire de règles. Il écrit un ensemble organisé de chaînes et de règles de saut dans iptables (ou nftables sur certains systèmes),
et le fait dans un ordre précis.
L’ordre est tout. La première règle correspondante gagne.

Voici le problème central : Docker insère des règles dans les tables nat et filter qui peuvent accepter le trafic redirigé vers les conteneurs
avant que la posture « deny incoming » d’UFW n’ait son mot à dire.
Le trafic n’est pas « entrant vers l’hôte » de la façon dont vous l’imaginez ; il est transféré à travers l’hôte vers un conteneur.

Chemin du paquet, simplifié mais exact

Lorsqu’un paquet arrive sur votre interface publique destiné à un port publié :

  • PREROUTING (nat) : Docker effectue un DNAT de la destination vers l’IP/port du conteneur.
  • FORWARD (filter) : Le noyau transfère le paquet depuis l’interface de l’hôte vers le bridge Docker.
  • DOCKER / DOCKER-USER (filter) : Les chaînes de Docker décident de ce qui passe.
  • Conteneur : Le service reçoit le paquet.

Les règles habituelles « deny incoming » d’UFW vivent majoritairement autour de la chaîne INPUT.
Mais les paquets transférés frappent FORWARD, pas INPUT.
Vous pouvez donc verrouiller la porte d’entrée tout en laissant la porte de côté grande ouverte.

La solution n’est pas mystique. Vous appliquez votre politique dans le chemin que Docker utilise : la chaîne DOCKER-USER.
Cette chaîne existe précisément pour que vous puissiez appliquer vos propres règles avant la logique d’acceptation de Docker.

Pourquoi UFW « perd » face à Docker (et pourquoi ce n’est pas un bug)

UFW a des opinions : il suppose que l’hôte est le point terminal.
Docker a des opinions : il suppose que l’hôte est un routeur pour les réseaux de conteneurs.
Mettez-les ensemble et vous obtenez une bataille de garde entre ces deux visions réseau.

La publication de ports par Docker est mise en œuvre avec des règles iptables qui :

  • font du DNAT dans nat/PREROUTING et nat/OUTPUT pour les connexions locales.
  • autorisent le transfert dans filter/FORWARD vers docker0 (ou un bridge défini par l’utilisateur).
  • gèrent leurs propres chaînes (comme DOCKER) et insèrent des sauts assez tôt pour avoir de l’impact.

UFW peut contrôler le forwarding, mais beaucoup d’installations laissent le forwarding permissif ou ne relient pas les règles de forwarding d’UFW pour préempter celles de Docker.
Et si votre modèle mental est « deny incoming signifie que rien n’atteint ma machine », vous manquerez la distinction entre INPUT et FORWARD.

Une citation qui reste dans la tête des ops depuis des décennies — appelez-la une idée paraphrasée de Gene Kranz (directeur de vol à la NASA) :
idée paraphrasée : « La robustesse et la compétence » l’emportent sur l’ingéniosité quand les choses tournent mal.
Les pare-feux ne sont pas l’endroit pour être rusé. Vous voulez qu’ils soient ennuyeux et corrects.

Blague n°1 : Une règle de pare-feu « ajoutée temporairement » pendant un incident a la même demi-vie que les déchets radioactifs — quelqu’un d’autre en héritera.

Faits intéressants et brève histoire à utiliser à 3 h du matin

  1. iptables évalue dans l’ordre : les règles sont vérifiées de haut en bas ; la première correspondance gagne. « J’ai ajouté un deny » ne veut rien dire si elle se trouve sous un accept.
  2. Docker a popularisé le modèle hôte-routeur : Docker utilisait par défaut le networking en bridge et le NAT, transformant effectivement chaque hôte en petit routeur de périphérie.
  3. UFW est une interface : il ne « fonctionne pas à côté » d’iptables ; il écrit des règles iptables et gère des chaînes. Si autre chose modifie iptables, UFW n’est pas devin.
  4. La chaîne DOCKER-USER existe pour une raison : elle a été ajoutée pour que les administrateurs puissent appliquer des politiques avant les règles gérées par Docker sans se battre en permanence avec lui.
  5. FORWARD est souvent négligée : beaucoup d’équipes durcissent INPUT mais oublient que la politique de forwarding compte dès que des conteneurs et des bridges entrent en jeu.
  6. conntrack est le lien stateful : des acceptations « ESTABLISHED,RELATED » peuvent faire paraître un port « ouvert » pour des flux existants même après que vous l’ayez « fermé ».
  7. La publication lie par défaut sur 0.0.0.0 : sauf si vous spécifiez une IP, Docker exposera sur toutes les interfaces de l’hôte. Cela inclut les interfaces publiques.
  8. La migration vers nftables est inégale : les distributions modernes peuvent adopter nftables par défaut, mais les interactions Docker/UFW peuvent encore être médiées via des couches de compatibilité iptables.

Playbook de diagnostic rapide

Quand quelqu’un dit « UFW est activé, mais le port est toujours ouvert », ne débattez pas de philosophie. Exécutez le playbook.
L’objectif est de trouver l’acceptation a lieu : INPUT, FORWARD ou DNAT Docker.

Première étape : confirmer ce qui est vraiment exposé

  • Vérifier les ports publiés par Docker et leurs adresses de liaison.
  • Confirmer les sockets à l’écoute sur l’hôte.
  • Tester depuis un point externe (pas depuis le même hôte).

Deuxième étape : cartographier le chemin du paquet

  • Inspecter les règles iptables/nftables : en particulier nat PREROUTING et filter FORWARD.
  • Trouver les chaînes Docker et leur ordre de saut.
  • Vérifier la chaîne DOCKER-USER — existe-t-elle et fait-elle quelque chose ?

Troisième étape : décider de la bonne stratégie de confinement

  • Si le service doit être interne uniquement : liez (bind) les ports publiés à 127.0.0.1 ou à une interface privée.
  • Si il doit être public mais restreint : appliquez la restriction dans DOCKER-USER par IP source, interface ou port de destination.
  • Si vous avez besoin d’une vraie politique périmétrique : privilégiez un pare-feu dédié en frontal (SG/NACL cloud, appliance matérielle, ou un pare-feu hôte avec politique DOCKER-USER stricte).

Tâches pratiques : commandes, sorties et décisions (12+)

Celles-ci sont exécutables sur un hôte Ubuntu typique avec Docker et UFW. Ajustez les noms d’interface selon le besoin.
Chaque tâche inclut : la commande, ce que signifie la sortie et la décision à prendre.

Tâche 1 : Confirmer le statut d’UFW et la politique par défaut

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    203.0.113.0/24

Sens : « disabled (routed) » est le drapeau rouge : le trafic routé/nat n’est pas gouverné par UFW.
Décision : Si vous comptez sur UFW pour bloquer l’exposition Docker, vous devez traiter le trafic routé/transféré (ou appliquer la politique dans DOCKER-USER).

Tâche 2 : Lister les ports publiés par Docker avec les adresses de liaison

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

Sens : web est exposé sur toutes les interfaces ; metrics est en localhost uniquement et ne sera pas accessible depuis l’extérieur.
Décision : Si un service ne doit pas être public, rebinder sur 127.0.0.1 est la victoire la plus simple.

Tâche 3 : Vérifier ce qui écoute sur l’hôte (sockets)

cr0x@server:~$ sudo ss -lntp | awk 'NR==1 || /:8080|:9090|:22/'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      4096   0.0.0.0:8080      0.0.0.0:*       users:(("docker-proxy",pid=1542,fd=4))
LISTEN 0      4096   127.0.0.1:9090    0.0.0.0:*       users:(("docker-proxy",pid=1611,fd=4))
LISTEN 0      4096   0.0.0.0:22        0.0.0.0:*       users:(("sshd",pid=912,fd=3))

Sens : Docker (ou docker-proxy) écoute sur 0.0.0.0:8080, qui est réellement exposé sauf filtrage au niveau du paquet.
Décision : N’assumez pas « c’est dans un conteneur donc c’est isolé ». Traitez-le comme tout autre écoute.

Tâche 4 : Confirmer les interfaces publiques et leurs adresses

cr0x@server:~$ ip -br addr
lo               UNKNOWN        127.0.0.1/8 ::1/128
ens3             UP             198.51.100.10/24 fe80::5054:ff:fe12:3456/64
docker0          DOWN           172.17.0.1/16

Sens : Tout ce qui est lié à 0.0.0.0 est joignable via ens3 sauf si filtré.
Décision : Décidez si l’exposition est voulue sur cette interface ; sinon liez explicitement ou bloquez sur cette interface.

Tâche 5 : Inspecter les règles NAT d’iptables de Docker (où se produit le DNAT)

cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,120p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER ! -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 9090 -j DNAT --to-destination 172.17.0.3:9090

Sens : Le trafic sur le port 8080 est DNATé vers un conteneur. Les règles INPUT d’UFW n’arrêtent pas le DNAT.
Décision : Vous devez contrôler l’acceptation du forwarding (filter/FORWARD) ou appliquer la politique dans DOCKER-USER.

Tâche 6 : Inspecter la chaîne FORWARD de la table filter (le piège habituel)

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

Sens : Même avec la politique FORWARD en DROP, Docker installe des acceptations. Crucialement, il saute vers DOCKER-USER en premier.
Décision : Placez vos règles deny/allow dans DOCKER-USER pour préempter les acceptations de Docker.

Tâche 7 : Vérifier ce qu’il y a actuellement dans DOCKER-USER

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

Sens : Aucune politique. Tout atteint RETURN puis les règles de forwarding permissives de Docker s’appliquent.
Décision : Ajoutez une politique explicite ici. DOCKER-USER vide équivaut à « faites-moi confiance » côté réseau.

Tâche 8 : Ajouter un deny par défaut pour le trafic conteneur transféré, puis autoriser ce que vous voulez

cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i ens3 -o docker0 -j DROP
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i ens3 -o docker0 -p tcp --dport 8080 -s 203.0.113.0/24 -j ACCEPT
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i ens3 -o docker0 -p tcp -m tcp --dport 8080 -s 203.0.113.0/24 -j ACCEPT
-A DOCKER-USER -i ens3 -o docker0 -j DROP
-A DOCKER-USER -j RETURN

Sens : L’interface publique vers docker0 est bloquée sauf pour le TCP/8080 depuis un sous-réseau de confiance.
Décision : C’est la posture « rendre l’exposition explicite ». Ajoutez des allow par port/service, puis conservez le drop.

Tâche 9 : Confirmer que les compteurs évoluent comme prévu

cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
 pkts bytes target  prot opt in   out     source           destination
   12   720 ACCEPT  tcp  --  ens3 docker0 203.0.113.0/24  0.0.0.0/0            tcp dpt:8080
  305 18300 DROP    all  --  ens3 docker0 0.0.0.0/0        0.0.0.0/0
    0     0 RETURN  all  --  *    *       0.0.0.0/0        0.0.0.0/0

Sens : Vous bloquez activement les accès tentés, et autorisez les sources prévues.
Décision : Si les compteurs sur DROP augmentent de façon inattendue, vous avez probablement exposé un port que vous n’aviez pas remarqué.

Tâche 10 : Rendre les règles persistantes (sinon elles disparaîtront au reboot)

cr0x@server:~$ sudo apt-get update
cr0x@server:~$ sudo apt-get install -y iptables-persistent
cr0x@server:~$ sudo netfilter-persistent save
run-parts: executing /usr/share/netfilter-persistent/plugins.d/15-ip4tables save
run-parts: executing /usr/share/netfilter-persistent/plugins.d/25-ip6tables save

Sens : Vos règles iptables actuelles sont sauvegardées et seront restaurées au démarrage.
Décision : Si vous gérez l’infrastructure via de l’automatisation, codez ces règles dans votre gestion de configuration plutôt que de compter sur l’état manuel du serveur.

Tâche 11 : Binder un port publié à localhost (souvent la meilleure correction)

cr0x@server:~$ docker run -d --name internal-admin -p 127.0.0.1:8081:80 nginx:alpine
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Ports}}' | grep internal-admin
internal-admin  127.0.0.1:8081->80/tcp

Sens : Le service n’est atteignable que depuis l’hôte lui-même (ou via un tunnel SSH/proxy inverse).
Décision : Utilisez ceci pour tableaux de bord, panneaux d’administration et tout ce que vous accédez via bastion de toute façon.

Tâche 12 : Vérifier la politique routed d’UFW si vous tenez à ce qu’UFW gère le forwarding

cr0x@server:~$ sudo ufw status verbose | grep -i routed
Default: deny (incoming), allow (outgoing), disabled (routed)

Sens : Le trafic routé est désactivé ; UFW ne gouverne pas le forwarding.
Décision : Soit activez la politique routed et intégrez-la soigneusement, soit arrêtez de prétendre qu’UFW contrôle seul l’exposition Docker et utilisez DOCKER-USER.

Tâche 13 : Vérifier le réglage de forwarding d’UFW via sysctl (le noyau peut vous veto)

cr0x@server:~$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1

Sens : Le routage IP est activé (commun sur les hôtes Docker).
Décision : Si vous désactivez le forwarding pour « corriger » l’exposition, attendez-vous à casser le networking des conteneurs. C’est un instrument brutal, pas un plan.

Tâche 14 : Voir l’ordre complet des règles autour de Docker et UFW

cr0x@server:~$ sudo iptables -S | sed -n '1,120p'
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-USER
-N ufw-before-input
-N ufw-user-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-user-input
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER
-A DOCKER-USER -j RETURN

Sens : Docker hooke FORWARD tôt. UFW s’accroche principalement à INPUT. Voilà l’histoire en 10 lignes.
Décision : Arrêtez d’attendre que la politique INPUT gouverne le trafic transféré.

Tâche 15 : Vérification externe rapide depuis une autre machine (test de réalité)

cr0x@server:~$ nc -vz 198.51.100.10 8080
Connection to 198.51.100.10 8080 port [tcp/http-alt] succeeded!

Sens : Le port est joignable depuis l’extérieur.
Décision : Si ce n’était pas voulu, corrigez l’adresse de liaison ou les règles DOCKER-USER, puis retestez jusqu’à ce que cela échoue depuis des sources non fiables.

Tâche 16 : Confirmer le paramètre « iptables management » de Docker (et pourquoi vous ne devriez généralement pas le désactiver)

cr0x@server:~$ sudo cat /etc/docker/daemon.json
{
  "log-driver": "journald",
  "iptables": true
}

Sens : Docker gère iptables, ce qui est le comportement par défaut normal.
Décision : Ne passez pas ceci à false à moins d’être prêt à prendre en charge entièrement NAT/forwarding/isolation et à déboguer des ruptures étranges ensuite.

Modèles de confinement qui tiennent réellement

Modèle A : Binder sur localhost ou une interface privée autant que possible

Si un service est utilisé uniquement par des processus locaux, un reverse proxy ou un tunnel SSH, liez-le à 127.0.0.1.
C’est plus propre que des règles de pare-feu car le noyau n’expose jamais la socket publiquement.

Dans Docker Compose, cela signifie :
publier comme 127.0.0.1:PORT:PORT.
C’est ennuyeux. C’est efficace. Vous pouvez toujours placer Nginx/Traefik/Caddy en frontal pour gérer délibérément le trafic public.

Modèle B : Utiliser DOCKER-USER comme grille de politique pour les ports publiés

Pensez à DOCKER-USER comme votre « chaîne équipe sécurité ».
Placez des drops par défaut là pour le trafic entrant depuis des interfaces publiques vers les bridges Docker,
puis ajoutez des allows explicites pour ce qui doit être joignable.

Le défaut sûr sur un hôte exposé à Internet est :

  • Autoriser established/related (ou laisser Docker le gérer).
  • Autoriser explicitement les ports publiés requis depuis des sources explicitement requises.
  • Bloquer le reste depuis l’interface publique(s) vers les bridge(s) docker.

Modèle C : Mettre une vraie couche de périmètre devant les hôtes Docker

Les pare-feux hôtes sont utiles, mais ne remplacent pas des garde-fous au niveau réseau.
Si vous pouvez utiliser un security group cloud, une appliance pare-feu dédiée, ou même un nœud d’ingress séparé, faites-le.
La défense en profondeur n’est pas un slogan ; c’est ce qui empêche « un mauvais fichier Compose » de devenir votre semaine.

Modèle D : Préférer un reverse proxy d’entrée plutôt que de publier chaque service

Si chaque conteneur publie son propre port vers le monde, vous avez construit un zoo de ports.
Un reverse proxy centralise TLS, l’auth et les décisions d’exposition. Il rend aussi la sortie des scans moins intéressante.

Blague n°2 : Si vous publiez -p 0.0.0.0:2375:2375 pour l’API Docker, félicitations — vous venez d’inventer le root à distance.

Trois mini-récits d’entreprise depuis le terrain

1) L’incident causé par une mauvaise hypothèse

Une entreprise SaaS de taille moyenne a migré une application legacy vers des conteneurs sur une poignée de VPS.
Le plan de migration était raisonnable : garder une empreinte réduite, contenir les coûts, et compter sur UFW parce que « nous l’utilisons déjà partout ».
La revue de sécurité fut un exercice de checklist. UFW : activé. Default deny : activé. Livré.

Quelques semaines plus tard, un nouvel outil interne est déployé en conteneur avec -p 8080:8080.
Il était destiné à un usage admin interne, accessible via VPN. L’ingénieur a supposé qu’UFW bloquerait le trafic non VPN parce que « deny incoming ».
Ce ne fut pas le cas. Le port était joignable depuis l’IP publique. Pas bruyamment, pas dramatiquement — juste joignable.

Le premier signal n’a pas été une alerte ; ce fut une facture surprise pour du trafic sortant et une plainte que l’outil était « lent ».
Quelqu’un avait trouvé le point d’entrée et bruteforcait les identifiants. L’auth du service était correcte, mais pas conçue pour l’internet ouvert.
Les logs montraient beaucoup d’échecs et quelques réussites depuis de l’espace IP générique.

Le postmortem ne chercha pas à blâmer Docker ou UFW.
Il portait sur l’écart du modèle mental : l’équipe considérait les conteneurs comme des processus « à l’intérieur de l’hôte » plutôt que comme des points d’accès atteints via forwarding et DNAT.
Une fois qu’ils appliquèrent un drop par défaut dans DOCKER-USER et rebondirent les ports internes sur localhost, la classe de défaillance disparut.

La leçon inconfortable : « Firewall activé » n’est pas un contrôle de sécurité. Une politique testée l’est.

2) L’optimisation qui a mal tourné

Une équipe d’entreprise exécutait des dizaines de services conteneurisés par nœud et voulait « un réseau plus rapide ».
Quelqu’un décida que la programmation iptables de Docker était un « surcoût » et désactiva l’intégration iptables de Docker dans la configuration du démon.
L’idée était de gérer tout le firewalling dans UFW et de garder le système « propre ».

Les premiers jours semblaient corrects, car la plupart du trafic est-ouest se faisait sur le même hôte et l’état conntrack masquait certains problèmes.
Puis un redémarrage planifié eut lieu — fenêtre de patch, rien d’exceptionnel.
Soudain, un sous-ensemble de services devint injoignable depuis d’autres hôtes, tandis que certains ports publiés se comportaient de façon incohérente.

L’équipe passa des heures à chasser des fantômes : DNS ? réseau overlay ? le nom du bridge avait-il changé ?
La cause réelle était plus simple : sans Docker pour gérer iptables, les règles NAT et de forwarding n’étaient pas correctement recréées après les redémarrages,
et les règles ad hoc UFW ne recréaient pas la plomberie de chaînes nécessaire à Docker.

Ils revinrent sur le changement, puis mirent en place le point de contrôle correct : DOCKER-USER pour la politique,
Docker-géré pour la plomberie. Ce partage des responsabilités est le compromis sain :
laissez Docker faire la plomberie ; vous décidez ce qui passe par les tuyaux.

3) La pratique ennuyeuse mais correcte qui a sauvé la mise

Une plateforme de services financiers exécutait des workloads conteneurisés sur des images Ubuntu durcies.
Rien d’extraordinaire. Leur arme secrète était une discipline opérationnelle terne : chaque hôte avait un job d’« audit d’exposition » standard.
Il tournait chaque nuit, exportait docker ps (mappings de ports), ss -lntp (listeners) et une vue filtrée de iptables -S vers un index de logs central.

Un après-midi, un développeur valida un changement Compose qui publiait accidentellement un endpoint de debug sur 0.0.0.0:6060.
Le service n’était pas « vulnérable » au sens CVE ; il n’était juste pas censé être joignable depuis le monde.
En moins d’une heure, la diff d’audit déclencha une alerte : nouveau listener public, pas dans la liste autorisée.

L’on-call n’eut pas besoin de se disputer avec qui que ce soit. Ils avaient la preuve : un nouveau listener et une nouvelle règle DNAT.
Ils revertirent le changement Compose, redéployèrent, et l’alerte s’effaça.
Aucun impact client. Pas de patch panique. Pas de réunion « comment c’est arrivé ? » ne produisant que des invitations calendaires.

La leçon n’est pas « les audits c’est cool ». La leçon est que la vérification répétée et ennuyeuse bat la confiance ponctuelle.
Voilà à quoi ressemble le « travail de fiabilité » quand il marche vraiment.

Erreurs courantes : symptômes → cause → correction

1) « UFW deny incoming, mais mon port de conteneur est toujours joignable »

Syndromes : Un scan externe atteint HOST:published_port même si UFW le bloque sur le papier.

Cause racine : Le trafic est transféré (chaîne FORWARD) après DNAT, pas livré à INPUT. Les règles Docker l’acceptent.

Correction : Ajouter une politique dans DOCKER-USER (drop par défaut depuis l’IF publique vers le bridge docker ; allow explicitement). Ou binder le port sur localhost/IP privée.

2) « J’ai ajouté des règles UFW pour bloquer le port, rien n’a changé »

Syndromes : ufw deny 8080/tcp n’a aucun effet sur les ports publiés.

Cause racine : Le bloc s’applique à INPUT ; le trafic est DNATé et transféré.

Correction : Bloquer dans DOCKER-USER ou désactiver la publication sur 0.0.0.0.

3) « Tout a planté après activation d’un firewall strict »

Syndromes : Les conteneurs ne peuvent plus atteindre Internet ; le networking inter-conteneurs échoue ; DNS dans les conteneurs est instable.

Cause racine : Règles DROP trop larges dans FORWARD/DOCKER-USER sans autoriser established ou les chemins egress requis.

Correction : Garder la mécanique de forwarding de Docker, mais appliquer une politique ciblée : allow established/related, autoriser le trafic bridge requis, puis drop l’ingress public vers les bridges.

4) « Mes règles ont marché jusqu’au reboot »

Syndromes : Après redémarrage, les ports sont à nouveau ouverts.

Cause racine : Les règles iptables ad hoc n’étaient pas persistées ; Docker a reconstruit ses règles ; les vôtres ont disparu.

Correction : Persister les règles via iptables-persistent/netfilter-persistent ou les encoder dans la gestion de configuration. Valider au reboot avec un test.

5) « J’ai bloqué l’exposition du conteneur, mais l’accès localhost a aussi été cassé »

Syndromes : Le reverse proxy sur l’hôte ne peut plus atteindre les backends conteneurs ; les checks de santé échouent.

Cause racine : La règle drop de DOCKER-USER est trop large (elle bloque le trafic provenant de l’hôte ou des interfaces internes).

Correction : Cibler les règles par interface (-i ens3 -o docker0) et/ou par plages source ; garder le trafic hôte→bridge autorisé.

6) « UFW et Docker se battent ; les règles paraissent dupliquées et bizarres »

Syndromes : Beaucoup de chaînes ; confusion ; comportement incohérent entre hôtes.

Cause racine : Backends nftables/iptables mélangés, différences de distro, ou plusieurs outils gérant l’état du firewall.

Correction : Choisir un seul plan de contrôle. Vérifier si vous utilisez la compatibilité iptables-nft. Standardiser les images. Tester le chemin du paquet réel, pas l’intention.

Listes de vérification / plan étape par étape

Plan 1 : Verrouiller un hôte Docker existant en toute sécurité (compatible production)

  1. Inventaire de l’exposition : lister les ports publiés et les listeners.

    • Utiliser docker ps et ss -lntp.
    • Décision : quels ports doivent être publics, privés ou localhost-only ?
  2. Identifier les interfaces publiques : ne pas deviner.

    • Utiliser ip -br addr.
    • Décision : quelles interfaces doivent pouvoir atteindre les conteneurs ?
  3. Confirmer l’ordre des chaînes Docker : s’assurer que DOCKER-USER est référencé tôt.

    • Utiliser iptables -S FORWARD.
    • Décision : si DOCKER-USER n’est pas présent, vous êtes dans une configuration non standard — corrigez cela avant de continuer.
  4. Implémenter une règle drop étroite pour l’ingress public vers les bridges Docker.

    • Commencer par un drop ciblé par interface : -i public -o docker0.
    • Décision : ajouter des allows au-dessus pour les ports/sources requis.
  5. Tester depuis l’extérieur et surveiller les compteurs.

    • Utiliser nc -vz depuis une autre machine ; vérifier les compteurs iptables.
    • Décision : itérer jusqu’à ce que seul l’accès voulu réussisse.
  6. Rendre persistant et automatiser.

    • Utiliser iptables-persistent ou gestion de configuration.
    • Décision : ajouter une gate CI/CD ou un audit nocturne pour détecter les nouvelles expositions.

Plan 2 : Construire de nouveaux hôtes avec « pas d’exposition accidentelle » par défaut

  1. Décider d’une stratégie d’ingress : un reverse proxy unique ou un petit ensemble de ports publics.
  2. Exiger des adresses de liaison explicites dans Compose pour tout ce qui ne doit pas être public (127.0.0.1:...).
  3. Déployer une politique DOCKER-USER par défaut : drop public IF vers bridge docker ; autoriser seulement les ports du proxy.
  4. Ajouter un job d’audit d’exposition (listeners + ports publiés + diff iptables).
  5. Tester le comportement au reboot : persistance du firewall et ordre de redémarrage de Docker.

FAQ

1) Pourquoi « ufw deny 8080/tcp » ne bloque-t-il pas le port publié par Docker ?

Parce que le trafic est DNATé et transféré vers un conteneur. Il n’est pas traité par INPUT comme le serait un processus hôte.
Il vous faut une politique dans FORWARD/DOCKER-USER ou supprimer la liaison publique.

2) Docker « contourne » UFW exprès ?

Docker programme iptables pour implémenter NAT et forwarding automatiquement. Cela peut contrecarrer vos attentes, mais ce n’est pas une fonction furtive.
Le crochet d’administration prévu est DOCKER-USER.

3) Dois-je désactiver la gestion iptables de Docker ?

Généralement non. Si vous la désactivez, vous devenez responsable du NAT, du forwarding, de l’isolation et des cas limites au redémarrage.
Laissez Docker faire la plomberie ; appliquez votre politique dans DOCKER-USER et en liant les ports avec soin.

4) Quel est l’ensemble de règles DOCKER-USER par défaut le plus sûr ?

Pour les hôtes exposés à Internet : autoriser les ports publiés explicites depuis des sources explicites, puis bloquer le trafic provenant des interfaces publiques vers les bridges Docker.
Garder le trafic local hôte et interne non bloqué sauf raison contraire.

5) Puis-je corriger cela uniquement avec UFW ?

Vous le pouvez, mais c’est fragile à moins de bien comprendre comment UFW s’insère dans FORWARD et comment Docker insère ses chaînes.
La voie plus sûre opérationnellement est d’utiliser DOCKER-USER pour la politique d’entrée conteneur, et UFW pour INPUT de l’hôte.

6) Pourquoi binder à 127.0.0.1 marche-t-il si bien ?

Parce que cela supprime l’exposition au niveau de la socket. Aucune acrobatie de filtrage de paquets nécessaire.
C’est la différence entre « bloqué » et « non joignable ».

7) Et l’IPv6 ?

Si IPv6 est activé, il faut appliquer des politiques équivalentes pour ip6tables/nft.
Sinon vous « verrouillez » IPv4 et laissez IPv6 largement ouvert par inadvertance. Auditez les deux piles.

8) Pourquoi je vois parfois docker-proxy écouter, et parfois non ?

Le comportement du proxy utilisateur Docker a évolué et peut être activé ou non. Même sans docker-proxy,
le DNAT iptables peut publier des ports. Vérifiez toujours à la fois la liste des sockets et les règles iptables.

9) Si j’utilise un conteneur reverse proxy, ai-je toujours besoin de règles DOCKER-USER ?

Oui, si vous voulez des garde-fous. Le proxy réduit la surface, mais un -p accidentel sur un backend peut toujours l’exposer.
DOCKER-USER rend cette erreur non fatale.

Prochaines étapes à faire aujourd’hui

Cessez de traiter « UFW activé » comme un résultat de sécurité. Sur un hôte Docker, c’est une condition de départ.
Votre vrai travail est de rendre l’exposition délibérée : lier ce qui peut l’être à localhost, et filtrer le reste dans DOCKER-USER.

  1. Faire l’inventaire : docker ps, ss -lntp, vérification externe.
  2. Inspecter l’ordre des règles : confirmer que DOCKER-USER est sauté tôt depuis FORWARD.
  3. Implémenter un drop par défaut depuis les interfaces publiques vers les bridges Docker dans DOCKER-USER, puis autoriser seulement ce que vous voulez.
  4. Persister les règles et tester le comportement au reboot.
  5. Ajouter un audit nocturne et ennuyeux d’exposition. Les choses ennuyeuses, c’est ce qui vous garde en sécurité.
← Précédent
IKEv2/IPsec : Quand c’est un meilleur choix que WireGuard ou OpenVPN
Suivant →
Debian 13 : Nginx renvoie soudainement 403/404 — permissions vs configuration, comment le distinguer instantanément

Laisser un commentaire