Réseau Docker : la mauvaise lecture NAT/pare-feu qui expose tout

Cet article vous a aidé ?

Vous verrouillez un hôte avec une politique de pare‑feu stricte. Vous lancez un conteneur avec « juste un port de test ». Quelques minutes plus tard, un scanner dans un autre fuseau horaire est déjà en train de s’y connecter. Vous n’avez rien « ouvert » — du moins pas dans votre tête.

Ceci est le piège du réseau Docker : une mauvaise lecture de l’interaction entre le NAT et les chaînes du pare‑feu, et votre modèle mental cesse de correspondre à la réalité du noyau. Le noyau gagne toujours. Assurons‑nous qu’il gagne en votre faveur.

La mauvaise lecture : « Mon pare‑feu décide de ce qui est joignable »

La méprise la plus coûteuse dans le réseau Docker est de penser que vos règles de pare‑feu hôte sont évaluées « normalement » pour les ports publiés des conteneurs.

Sur un hôte Linux utilisant le réseau bridge par défaut de Docker, Docker installe des règles iptables qui réécrivent le trafic (DNAT) et le dirigent vers les conteneurs. Si votre politique de pare‑feu suppose un simple « INPUT décide, puis ACCEPT/DROP », vous pouvez vous tromper de deux façons à la fois :

  • Vous pourriez filtrer la mauvaise chaîne. Les ports publiés des conteneurs sont typiquement du trafic transféré, pas de la livraison locale. Cela signifie que la décision se trouve souvent dans FORWARD, pas dans INPUT.
  • Vous pourriez filtrer trop tard. Les réécritures NAT se produisent avant le filtrage d’une manière qui change ce que vos règles « voient ». Vous pensez bloquer le port 8080 sur l’hôte, mais après DNAT c’est « port 80 vers 172.17.0.2 », et votre règle ne correspond plus.

Le rôle de Docker est de rendre les conteneurs joignables. Le vôtre est de décider depuis où. Si vous n’affirmez pas explicitement cette frontière au bon endroit, Docker se chargera volontiers de la partie « joindre » pour l’ensemble d’internet.

Vérité sèche : la posture par défaut de « publier un port » est « le rendre joignable ». La posture par défaut de « exécuter sur une VM cloud » est « supposez qu’on vous scanne ». Combinez les deux et vous obtenez un ticket que personne n’apprécie.

Le vrai trajet des paquets : conntrack, NAT, filter, et où Docker s’accroche

Un modèle mental minimal qui ne vous ment pas

Quand un paquet arrive sur un hôte Linux, le noyau ne demande pas poliment à votre pare‑feu si son existence est acceptable. Il classe le paquet, consulte conntrack, l’envoie dans les hooks NAT, puis dans les hooks de filtrage, puis effectue le routage. L’ordre compte.

Pour une connexion TCP entrante typique vers un port Docker publié sur l’hôte (par exemple, 203.0.113.10:443), les étapes importantes sont :

  1. PREROUTING (nat) : La règle DNAT de Docker peut réécrire la destination depuis l’IP:port de l’hôte vers l’IP:port du conteneur.
  2. Décision de routage : Après le DNAT, le noyau peut décider que ce trafic n’est pas destiné à l’hôte lui‑même mais doit être transféré vers une interface bridge (docker0).
  3. FORWARD (filter) : C’est là que beaucoup de pare‑feux hôtes oublient de regarder. Docker ajoute des règles d’acceptation pour les flux établis et pour les ports publiés.
  4. POSTROUTING (nat) : Pour le trafic sortant des conteneurs, Docker applique typiquement du SNAT/MASQUERADE afin que les réponses ressemblent à provenir de l’hôte.

Les chaînes Docker importantes (backend iptables)

Sur les systèmes utilisant le backend iptables, Docker crée et utilise généralement ces chaînes :

  • DOCKER (dans nat et filter) : contient les règles DNAT et certaines règles de filtrage pour les réseaux de conteneurs.
  • DOCKER-USER (dans filter) : votre point d’insertion recommandé. Docker saute ici tôt pour que vous puissiez appliquer votre politique.
  • DOCKER-ISOLATION-STAGE-1/2 : utilisés pour isoler les réseaux Docker entre eux.

Pourquoi les règles UFW/firewalld peuvent sembler correctes et pourtant échouer

Beaucoup d’outils de pare‑feu au niveau hôte sont des wrappers. Ils génèrent des règles iptables/nftables dans certaines chaînes avec certaines priorités. S’ils se concentrent sur INPUT alors que votre trafic conteneur est transféré, votre « refus » n’a jamais voix au chapitre.

En plus, Docker peut insérer des règles avant celles gérées par votre distribution, selon la façon dont votre pare‑feu est construit. Le paquet correspondra à la première règle acceptable et n’atteindra jamais votre refus soigneusement conçu.

Une idée paraphrasée de Werner Vogels (CTO d’Amazon) : « Tout échoue tout le temps ; concevez et opérez comme si l’échec était normal. » Appliquez cela aussi à la sécurité : supposez que la mauvaise configuration est normale, et construisez des garde‑fous.

Faits intéressants et contexte historique

  • Linux netfilter précède Docker d’une décennie. iptables est devenu courant au début des années 2000, construit sur des hooks netfilter dans le noyau.
  • Conntrack est de l’état, pas de la magie. Le suivi de connexion permet les règles « ESTABLISHED,RELATED » qui rendent les pare‑feux utilisables, mais cela signifie aussi qu’un paquet autorisé peut créer un flux autorisé de longue durée.
  • Le bridge par défaut de Docker est un bridge Linux classique. Ce n’est pas un « commutateur Docker » spécial ; c’est le même primitif utilisé par les VM et les namespaces réseau depuis des années.
  • « Publier » signifie « binder sur toutes les interfaces » sauf indication contraire. -p 8080:80 se lie typiquement à 0.0.0.0 (et souvent ::) à moins que vous précisiez une IP.
  • Docker a introduit DOCKER-USER comme concession à la réalité. Les gens avaient besoin d’un endroit stable pour appliquer une politique qui survive aux redémarrages de Docker et qui ne soit pas réécrite.
  • nftables n’a pas remplacé iptables du jour au lendemain en pratique. Beaucoup de distributions sont passées à nftables en interne, mais les outils, les attentes et l’intégration Docker ont mis des années à suivre.
  • Hairpin NAT est plus ancien que votre incident. Le problème « l’hôte se parle à lui‑même via l’IP publique » existe dans de nombreux dispositifs NAT ; Docker peut déclencher des comportements similaires sur une seule machine.
  • Le réseau ingress de Swarm utilise sa propre plomberie. Le routing mesh peut publier des ports sur plusieurs nœuds, ce qui surprend les équipes qui s’attendent à « seulement le nœud avec la tâche est exposé ».
  • Les security groups cloud ne remplacent pas la politique hôte. Ils constituent une couche, pas une garantie — des règles mal appliquées ou des changements ultérieurs peuvent encore vous exposer.

Comment l’« exposition » se produit réellement (quatre modes courants)

Mode 1 : Vous avez publié un port et oublié qu’il se lie au monde

docker run -p 8080:80 … est pratique. C’est aussi une exposition explicite. Si vous vouliez « seulement localhost », il faut le préciser : -p 127.0.0.1:8080:80.

Ceci est le bug « je ne pensais pas que ce serait public ». Docker pensait que ce serait public. Docker avait raison.

Mode 2 : Votre pare‑feu filtre INPUT, mais le trafic Docker passe par FORWARD

Si le paquet est DNATé vers une IP de conteneur, il n’est plus destiné à l’hôte. Cela le pousse dans la logique de forwarding. Si votre posture de sécurité ignore FORWARD, vous avez laissé une porte dérobée — grande ouverte.

Mode 3 : Vous comptez sur les valeurs par défaut de UFW/firewalld qui ne prennent pas en compte les chaînes Docker

Certaines gestionnaires de pare‑feu définissent la politique de forward par défaut sur ACCEPT, ou ne gèrent pas du tout les chaînes Docker. Vous pouvez vous retrouver avec un pare‑feu qui semble restrictif pour l’hôte, tandis que les conteneurs ont une voie rapide séparée.

Mode 4 : Vous avez optimisé pour la performance et supprimé involontairement un point de contrôle

Désactiver conntrack, changer les réglages bridge-nf-call-iptables, basculer de backend iptables, ou activer des fonctionnalités « fast path » peut tous modifier quelles règles s’exécutent et quand. C’est là que « ça marchait en staging » meurt.

Blague #1 : Le NAT, c’est comme un organigramme d’entreprise — tout y transite, et personne n’admet en être responsable.

Tâches pratiques : commandes, sorties et la décision que vous prenez

Ce ne sont pas des « exécutez ceci parce qu’un blog l’a dit ». Chacune vous dit quelque chose de concret. Chacune a une décision attachée.

Tâche 1 : Lister les ports publiés et leurs adresses de liaison

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

Ce que cela signifie : web est joignable sur toutes les interfaces IPv4 et IPv6. metrics est uniquement localhost. db n’est pas publié du tout.

Décision : Si ce n’est pas censé être accessible depuis internet, arrêtez‑vous et relancez avec une IP de liaison explicite ou retirez la publication. Ne « corrigez le pare‑feu plus tard » que si vous contrôlez aussi DOCKER-USER.

Tâche 2 : Confirmer ce qui écoute réellement sur l’hôte

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

Ce que cela signifie : Docker expose 8080 sur toutes les interfaces. Même si des règles iptables existent, une socket en écoute est la première étape de la joignabilité.

Décision : Si vous voyez 0.0.0.0 ou :: et que ce n’était pas voulu, corrigez d’abord les flags de publication ou la définition du service.

Tâche 3 : Vérifier l’utilisation de docker-proxy (et ne pas supposer qu’il a disparu)

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

Ce que cela signifie : Certaines configurations utilisent encore le proxy userland pour les ports publiés. Cela peut modifier la façon dont les paquets traversent le pare‑feu hôte et affecter la journalisation.

Décision : Si vous dépannez un « pourquoi ma règle INPUT n’a pas matché », notez si le trafic est proxifié localement ou bien transféré vers un conteneur.

Tâche 4 : Voir les règles iptables de Docker dans la table nat (où se produit le DNAT)

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

Ce que cela signifie : Tout paquet destiné aux adresses locales sur le port 8080 peut être DNATé vers le conteneur. Cela inclut les IP publiques de l’hôte.

Décision : Si vous devez restreindre les sources, faites‑le dans DOCKER-USER (filter) ou ajustez la publication/la liaison ; ne luttez pas contre le DNAT avec des règles INPUT tardives.

Tâche 5 : Vérifier l’ordre de la table filter, en particulier DOCKER-USER

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

Ce que cela signifie : Docker saute dans DOCKER-USER tôt. C’est bien : vous avez un point d’étranglement. La politique FORWARD par défaut est DROP ici, ce qui est également bien.

Décision : Placez vos listes d’autorisation/denis dans DOCKER-USER. Si votre système n’a pas ce jump, vous devez corriger ce modèle de politique immédiatement.

Tâche 6 : Inspecter la chaîne DOCKER-USER (votre point d’application)

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

Ce que cela signifie : Aucune politique n’est appliquée pour le trafic transféré vers les conteneurs. Tout passe inchangé.

Décision : Ajoutez des règles explicites : refuser par défaut et n’autoriser que ce qui doit être joignable.

Tâche 7 : Ajouter en sécurité une règle « refuser par défaut l’inbound vers les conteneurs »

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

Ce que cela signifie : Les nouvelles connexions entrantes arrivant sur eth0 et transférées vers des conteneurs via docker0 seront abandonnées. Les flux établis restent autorisés via d’autres règles.

Décision : C’est votre frein d’urgence. Après l’avoir appliqué, autorisez sélectivement les ports publiés requis depuis les sources nécessaires.

Tâche 8 : Autoriser un service publié spécifique depuis une plage source spécifique

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

Ce que cela signifie : Vous autorisez uniquement la plage source prévue à atteindre ce port de conteneur, en amont du drop par défaut.

Décision : Préférez la liste d’autorisations par source et destination quand c’est possible. Si vous ne pouvez pas, au moins restreignez par interface et port.

Tâche 9 : Vérifier du point de vue de l’hôte quelle route et quelle interface le forwarding utilise

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

Ce que cela signifie : Le trafic vers l’IP du conteneur passe par docker0. Cela valide que votre interface correspond aux règles du pare‑feu.

Décision : Si vous utilisez des réseaux personnalisés (par ex. br-*), mettez à jour les règles pour correspondre aux interfaces de sortie correctes.

Tâche 10 : Vérifier les sysctls bridge netfilter qui changent si le trafic bridge passe par iptables

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

Ce que cela signifie : Le trafic ponté traversera les règles iptables. Si ces valeurs sont 0, certaines attentes de filtrage se cassent et vous pouvez vous retrouver avec une confusion « les règles du pare‑feu ne fonctionnent pas ».

Décision : Gardez‑les activées sauf si vous comprenez profondément les compromis et disposez d’un autre mécanisme d’application.

Tâche 11 : Déterminer si iptables utilise le backend nft (c’est important pour le débogage)

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

Ce que cela signifie : Les commandes iptables manipulent nftables en coulisse. L’ordre des règles et la coexistence avec des règles nft natives peuvent surprendre.

Décision : Lors du dépannage, utilisez aussi nft list ruleset, pas seulement la sortie iptables.

Tâche 12 : Inspecter le ruleset nftables pour l’interaction Docker

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

Ce que cela signifie : Vous voyez la même structure logique en termes nftables : NAT en PREROUTING, filtrage en forward, et votre politique DOCKER-USER.

Décision : Si votre distribution utilise nftables nativement, envisagez de gérer la politique Docker en nft directement pour la cohérence — mais assurez‑vous que Docker conserve ses chaînes stables.

Tâche 13 : Confirmer si un port est joignable depuis un point externe

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

Ce que cela signifie : Il est joignable. Aucun « je pensais que le pare‑feu… » ne change cela.

Décision : Si cela ne devrait pas être joignable, arrêtez le trafic dans DOCKER-USER ou retirez la publication et retestez. Vérifiez ensuite l’IPv6 séparément.

Tâche 14 : Vérifier explicitement l’exposition IPv6

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

Ce que cela signifie : Vous écoutez aussi sur IPv6. Si votre security group ou pare‑feu ne considérait que l’IPv4, vous pouvez avoir une exposition IPv6 accidentelle.

Décision : Sécurisez l’IPv6 de façon équivalente ou désactivez‑le délibérément pour le service. « Nous n’utilisons pas IPv6 » n’est pas un contrôle.

Tâche 15 : Tracer le parcours des paquets avec des compteurs (votre règle a‑t‑elle matché ?)

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

Ce que cela signifie : Les compteurs racontent l’histoire : la règle d’accept a matché 12 paquets ; la règle de drop bloque activement les nouvelles tentatives.

Décision : Si les compteurs ne bougent pas, vous regardez la mauvaise chaîne ou la mauvaise interface. Arrêtez de deviner ; suivez les compteurs.

Tâche 16 : Vérifier les réseaux Docker et les noms d’interfaces bridge

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

Ce que cela signifie : Les réseaux bridge définis par l’utilisateur créent des interfaces br-*. Si vous n’avez écrit des règles que pour docker0, d’autres réseaux peuvent rester exposés.

Décision : Mettez à jour la politique du pare‑feu pour couvrir toutes les interfaces bridge Docker, pas seulement la valeur par défaut.

Mode d’emploi pour un diagnostic rapide

Si vous suspectez qu’un port de conteneur est exposé (ou qu’un pare‑feu « ne fonctionne pas »), ne prenez pas le chemin touristique. Faites ceci dans l’ordre.

Première étape : identifier la surface d’exposition

  1. Lister les ports publiés (docker ps avec Ports) et repérer 0.0.0.0 / ::.
  2. Vérifier les sockets en écoute (ss -lntp) pour confirmer ce qui est lié et par quel processus.
  3. Tester la joignabilité depuis l’extérieur (ou un jump host) avec nc ou curl.

Deuxième étape : localiser le point de décision dans netfilter

  1. Trouver les règles DNAT dans iptables -t nat -S (ou la table nat nft).
  2. Vérifier l’ordre de la chaîne FORWARD et confirmer que DOCKER-USER est appelé tôt.
  3. Utiliser les compteurs (iptables -L -v) pour voir quelles règles correspondent réellement.

Troisième étape : corriger avec le changement le moins « malin »

  1. Privilégier la réduction de la liaison (-p 127.0.0.1:…) ou supprimer -p entièrement.
  2. Appliquer une politique de base dans DOCKER-USER : drop par défaut pour les nouvelles connexions entrantes vers les bridges Docker ; allowlist ce qui doit être public.
  3. Retester la joignabilité externe et valider que les compteurs ont évolué comme prévu.

Blague #2 : Si vous « ouvrez juste un port pour cinq minutes », vos attaquants sont ponctuels.

Erreurs courantes : symptôme → cause racine → correction

1) « UFW dit que c’est bloqué, mais le conteneur est quand même joignable »

Symptôme : UFW refuse 8080, mais nc depuis internet se connecte.

Cause racine : Le trafic est transféré vers un conteneur (FORWARD), alors que les règles UFW couvrent principalement INPUT. Les règles iptables de Docker permettent le forwarding.

Correction : Mettez la politique dans DOCKER-USER. Soit drop par défaut les nouvelles connexions entrantes vers les bridges Docker, soit allowliste des sources/ports spécifiques.

2) « J’ai bloqué le port 8080 sur INPUT, mais il fonctionne encore »

Symptôme : Une règle INPUT drop pour tcp/8080 ne fait rien.

Cause racine : Le DNAT en PREROUTING change la destination en IP:port du conteneur ; le paquet ne correspond plus à INPUT pour une livraison locale.

Correction : Filtrez dans FORWARD (idéalement dans DOCKER-USER). Ou ne publiez pas sur 0.0.0.0 dès le départ.

3) « C’est uniquement exposé en IPv6 et personne ne l’a remarqué »

Symptôme : L’IPv4 est verrouillée ; un scanner IPv6 signale un port ouvert.

Cause racine : Docker a publié sur :: ; le pare‑feu IPv6 n’est pas équivalent ; le security group ignore l’IPv6.

Correction : Ajoutez des règles IPv6 ; liez explicitement à IPv4 localhost si c’est l’intention ; ou désactivez IPv6 avec précaution et connaissance.

4) « Après avoir activé nftables, le réseau Docker est devenu bizarre »

Symptôme : Les ports publiés échouent de manière intermittente, ou les règles n’apparaissent pas là où vous les attendiez.

Cause racine : Gestion mixte : règles nft natives plus traduction iptables-nft plus chaînes générées par Docker. La priorité et l’ordre des hooks diffèrent des hypothèses.

Correction : Standardisez : gérez via iptables de façon cohérente (en gardant à l’esprit le backend nft) ou adoptez une politique nftables cohérente qui respecte toujours les chaînes Docker.

5) « Les conteneurs sur un bridge personnalisé sont exposés même si docker0 est verrouillé »

Symptôme : Les règles ciblant docker0 fonctionnent, mais les conteneurs sur br-* sont accessibles.

Cause racine : Les réseaux définis par l’utilisateur utilisent d’autres interfaces bridge ; vos règles ne les couvrent pas.

Correction : Matchez sur oifname "br-*" (nft) ou ajoutez des règles d’interface pour chaque bridge, ou groupez‑les avec des ipsets/ensembles nft.

6) « Swarm a publié un port partout »

Symptôme : Un service publié sur un nœud paraît joignable sur tous les nœuds.

Cause racine : Le routing mesh de Swarm (ingress) publie le port au niveau du cluster ; le trafic est transféré en interne vers une tâche active.

Correction : Utilisez le mode host pour une exposition locale au nœud, ou imposez des restrictions en bordure avec des pare‑feux/load balancers, et considérez l’ingress Swarm comme un plan d’exposition partagé.

Trois mini‑récits d’entreprise sur le terrain

Mini‑récit 1 : L’incident causé par une mauvaise hypothèse

Une société SaaS de taille moyenne a migré un service legacy vers des conteneurs sur une paire de VM cloud. L’équipe avait l’habitude bien ancrée : verrouiller tout au pare‑feu hôte, puis n’ouvrir que pour le reverse proxy et le SSH depuis le VPN.

Pendant la migration, un ingénieur a publié un port pour une interface d’administration interne : -p 8443:8443. L’hypothèse était raisonnable et héritée : « Le pare‑feu hôte le bloque à moins qu’on l’ouvre. » Ils ne l’avaient pas ouvert.

Deux jours plus tard, l’équipe sécurité a signalé du trafic sortant vers une plage IP inconnue. L’interface d’administration n’a pas été « piratée » façon Hollywood ; elle était simplement joignable. L’UI avait une authentification basique, mais aussi un endpoint déclenchant des jobs coûteux en arrière‑plan. Un inconnu d’internet l’a trouvée et l’a traitée comme un radiateur crypto gratuit.

Le débat post‑incident était prévisible. L’équipe applicative a pointé le pare‑feu. L’infra a accusé l’app d’avoir publié un port. Les deux avaient partiellement raison, ce qui favorise les incidents répétés.

La correction réelle fut ennuyeuse : appliquer un drop par défaut explicite pour les nouvelles connexions entrantes vers les interfaces bridge Docker dans DOCKER-USER, et exiger que les services se lient à 127.0.0.1 sauf si un ticket prouve le besoin d’accès externe. La prochaine UI admin fut publiée sur localhost puis routée via le reverse proxy avec authentification et logging appropriés.

Mini‑récit 2 : L’optimisation qui s’est retournée contre eux

Une plateforme proche de la finance a cherché à réduire la latence. Quelqu’un a remarqué l’overhead du traitement des paquets et a proposé de « simplifier le firewalling » en se reposant davantage sur les security groups en amont et moins sur les règles hôtes. Dans la même fenêtre de changements, ils ont basculé quelques sysctls noyau et nettoyé ce qu’ils considéraient comme des chaînes iptables « redondantes ».

La performance s’est effectivement améliorée — suffisamment pour embellir des graphiques en réunion. Puis un problème apparemment sans lien est apparu : un service conteneurisé était joignable depuis un segment réseau qui n’était pas censé le contacter. Pas l’internet public, mais un sous‑réseau interne large avec trop d’ordinateurs portables et trop de curiosité.

La cause racine n’était pas un seul changement ; c’était la combinaison. Supprimer le filtrage forward au niveau hôte et modifier le comportement bridge‑netfilter signifiait que certains chemins de trafic n’atteignaient plus les points d’application prévus. Le security group en amont « semblait correct », mais à l’intérieur du VPC le rayon d’action s’était élargi.

Ils n’ont pas annulé tout le travail de performance. Ils ont réintroduit l’application à DOCKER-USER avec un jeu minimal de règles et des correspondances étroites, et ont mesuré honnêtement l’impact sur la latence. C’était minime. La réduction de risque, elle, était réelle.

Mini‑récit 3 : La pratique ennuyeuse qui a sauvé la mise

Une entreprise du secteur santé (beaucoup de conformité, audits et cases à cocher) avait une règle : tout port de conteneur publié vers autre chose que localhost doit être justifié, documenté et testé depuis un point de vue externe dans le pipeline de changements.

Ce n’était pas glamour. Les ingénieurs râlaient. Mais la pipeline incluait une étape simple : déployer sur un hôte canari, exécuter une petite suite de contrôles de connectivité externe, et échouer le changement si des ports censés être privés étaient joignables.

Un vendredi, un propriétaire de service a tenté de « publier temporairement » un port de debug sur toutes les interfaces pour qu’un prestataire teste. La demande l’indiquait, mais l’évaluation des risques était vague. Le pipeline l’a détecté car le port était joignable depuis un réseau non approuvé dans l’environnement de test.

La correction n’a pas été héroïque. C’était un petit changement de configuration : publication vers 127.0.0.1 et accès via un tunnel SSH éphémère depuis un bastion contrôlé. Le prestataire a pu tester. Le port n’est jamais devenu un point d’entrée internet surprenant. Tout le monde est rentré chez soi à l’heure, ce qui est le vrai KPI.

Listes de contrôle / plan pas à pas

Checklist de durcissement de base (hôte Docker unique)

  1. Inventaire de l’exposition : lister les ports publiés (docker ps) et les sockets en écoute (ss -lntp).
  2. Décider l’intention par port : public, VPN‑seulement, interne‑seulement ou localhost‑seulement.
  3. Rendre la liaison explicite : utiliser -p 127.0.0.1:HOST:CONTAINER pour localhost‑only ; spécifier l’IP d’une interface interne si besoin.
  4. Appliquer une politique par défaut dans DOCKER-USER : drop des nouvelles connexions entrantes transférées par défaut.
  5. Allowlister intentionnellement : ajouter des acceptations étroites avant le drop — par plage source, IP/port du conteneur et interface quand possible.
  6. Valider l’IPv6 : vérifier les écouteurs et la parité des règles de pare‑feu ; ne supposez pas qu’il est désactivé.
  7. Persister les règles : assurez‑vous que vos règles DOCKER-USER survivent aux redémarrages (système spécifique : iptables-persistent, configuration nftables, etc.).
  8. Retester depuis l’extérieur : vérifier que la joignabilité correspond à l’intention.

Pas à pas : convertir une « publication publique » en « privée derrière un reverse proxy »

  1. Changer la publication pour localhost :
    • Passer de -p 8080:80 à -p 127.0.0.1:8080:80.
  2. Placer devant un reverse proxy sur l’hôte ou un conteneur edge dédié qui est le seul point exposé sur internet.
  3. Appliquer le drop de base DOCKER-USER pour les nouvelles connexions entrantes vers les interfaces bridge, afin qu’« un quelqu’un republie un port » ne devienne pas une exposition instantanée.
  4. Ajouter auth, limites de débit et logs au niveau du reverse proxy ; les services conteneurisés ne doivent pas réinventer la sécurité périmétrique.
  5. Exécuter une validation externe avec nc/curl depuis un réseau non approuvé et confirmer l’échec.

Pas à pas : confinement d’urgence quand vous suspectez une exposition

  1. Appliquer un drop d’urgence dans DOCKER-USER pour les nouvelles connexions entrantes vers les bridges Docker (scopé par interface) pour arrêter l’hémorragie.
  2. Confirmer que la joignabilité externe cesse via un test depuis l’extérieur.
  3. Identifier les ports publiés et les supprimer ou les restreindre au niveau Docker run/compose.
  4. Remplacer le drop d’urgence par des règles allowlist afin que les services requis restent joignables.
  5. Auditer l’exposition IPv6 et appliquer le confinement équivalent là‑bas aussi.

FAQ

1) Est‑ce que « EXPOSE » dans un Dockerfile équivaut à publier un port ?

Non. EXPOSE est de la métadonnée. La publication se produit avec -p/--publish ou compose ports:. Seule la publication crée une joignabilité depuis l’hôte.

2) Pourquoi ma chaîne INPUT ne voit‑elle pas le trafic vers des ports publiés de conteneurs ?

Parce qu’après DNAT, le trafic est routé vers une IP de conteneur et devient du trafic transféré. Il est typiquement filtré dans FORWARD, pas dans INPUT.

3) Où dois‑je placer ma politique au niveau hôte pour le trafic Docker ?

Dans DOCKER-USER. C’est conçu comme le point d’insertion stable que Docker ne réécrira pas au redémarrage.

4) Si je me lie à 127.0.0.1, suis‑je en sécurité ?

Vous êtes plus en sécurité. La liaison localhost empêche l’accès réseau externe au niveau de la socket. Pensez toutefois aux menaces internes, aux compromissions locales et à la possibilité qu’un autre processus le proxifie vers l’extérieur.

5) Docker utilise‑t‑il toujours docker-proxy pour les ports publiés ?

Non. Le comportement varie selon la version de Docker, les capacités du noyau et la configuration. Ne supposez pas un seul chemin de paquet — confirmez avec ss et la liste des processus.

6) Comment l’IPv6 change‑t‑elle l’histoire ?

Elle ajoute un second plan d’exposition. Vous pouvez avoir un « IPv4 sécurisé » et un « IPv6 ouvert » en même temps. Validez les écouteurs et les règles de pare‑feu pour les deux.

7) J’utilise nftables. Dois‑je arrêter d’utiliser les commandes iptables ?

Si votre système exécute iptables avec le backend nft, les commandes iptables fonctionnent toujours mais peuvent masquer l’ordre final des règles. Pour un débogage approfondi, inspectez nft list ruleset.

8) Qu’en est‑il de Docker rootless — évite‑t‑il ces pièges de pare‑feu ?

Le mode rootless modifie la mise en réseau et les mécanismes de publication de ports, et il peut réduire le rayon d’action des privilèges du démon. Il n’enlève pas la nécessité de réfléchir clairement à ce qui est joignable et depuis où.

9) Docker Compose change‑t‑il quelque chose à tout cela ?

Pas de changement fondamental. Compose est une interface plus agréable pour les mêmes primitives. Si compose indique ports: - "8080:80", vous publiez vers le monde à moins de préciser une IP.

10) Je fais confiance uniquement aux security groups cloud. Puis‑je ignorer le pare‑feu hôte ?

Vous le pouvez, jusqu’à ce qu’un security group soit mal appliqué, copié depuis un autre environnement ou modifié sous pression. La défense en profondeur n’est pas un slogan ; c’est ce qui empêche « un mauvais jour » de devenir « un mauvais trimestre ».

Étapes suivantes que vous pouvez faire aujourd’hui

  1. Auditez chaque hôte : exécutez docker ps et ss -lntp. Notez ce qui est lié à 0.0.0.0 et ::.
  2. Établissez une politique d’entrée par défaut pour les conteneurs : implémentez un drop de base pour les nouvelles connexions transférées dans DOCKER-USER, puis allowliste explicitement.
  3. Rendez la publication intentionnelle : exigez des IPs de liaison explicites dans les définitions compose/service ; par défaut, lier à localhost et router via un proxy edge.
  4. Testez comme un attaquant : validez la joignabilité depuis l’extérieur de votre segment réseau, y compris l’IPv6.
  5. Opérationnalisez‑le : persistez les règles, ajoutez des contrôles CI/CD pour détecter les ports publiés non intentionnels, et traitez les changements pare‑feu/Docker comme des changements de production avec rollback.

Si vous retenez une chose : Docker ne « contourne » pas votre pare‑feu par malice. Il utilise le noyau exactement comme conçu. Votre travail est de placer la politique à l’endroit que le noyau consulte réellement.

← Précédent
Pas d’IOMMU détecté sur Proxmox ? Corrigez-le en 10 minutes (UEFI + options du noyau)

Laisser un commentaire