jguillaumesio
prod-opsdevopssecuritydocker

Docker a contourné mon pare-feu : le port de la base était ouvert sur internet

Mon pare-feu bloquait le port 5432. Il était joignable depuis internet quand même, parce que Docker écrit ses propres règles iptables que vos règles INPUT et ufw ne voient jamais. Pourquoi, et comment vraiment le fermer.

J’avais un pare-feu. J’avais explicitement bloqué le port Postgres sur l’hôte. Et Postgres était quand même joignable depuis internet.

Je l’ai découvert de la pire façon : la base recevait des tentatives de connexion depuis des adresses IP qui n’avaient rien à faire là. Mes règles de pare-feu disaient que le port 5432 était fermé. Un scan depuis une machine hors du serveur disait qu’il était grand ouvert. Les deux disaient vrai, et l’écart entre les deux est l’un des pièges de production les plus courants dans le monde Docker.

Voici la partie 2 d’une série sur le durcissement d’un SaaS construit en solo en production. Le setup et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle du jour où j’ai appris que Docker se moque du pare-feu que je croyais avoir.

Pourquoi le pare-feu ne servait à rien

Voici le modèle mental que la plupart des gens ont, et pourquoi il est faux. Vous imaginez le trafic qui arrive sur l’hôte, passe par votre chaîne INPUT, et se fait jeter par votre règle. Des outils comme ufw renforcent cette idée : vous tapez ufw deny 5432, il dit que la règle est active, et vous passez à autre chose.

Mais un port Docker publié ne passe pas par INPUT. Quand vous publiez un port, Docker réécrit le pare-feu à votre place. Il ajoute une règle DNAT dans la table nat qui redirige le trafic entrant vers le conteneur, et il accepte ce trafic via la chaîne FORWARD, parce que du point de vue du noyau le paquet est routé vers un autre réseau (celui du conteneur), pas livré à l’hôte lui-même.

Votre ufw deny et vos règles INPUT ... DROP écrites à la main gouvernent la chaîne INPUT. Les ports publiés par Docker sont traités dans FORWARD. Les deux ne se croisent jamais. Donc le pare-feu faisait exactement ce que je lui avais demandé : jeter le trafic vers l’hôte sur 5432. Docker, lui, transférait discrètement le trafic vers le conteneur sur 5432, par un chemin complètement différent, et il gagne parce qu’il passe en premier.

La commodité qui rend Docker agréable (publier un port et ça marche tout seul) est le mécanisme exact qui a percé un trou dans mon pare-feu sans un mot.

Le seul binding qui était déjà bon

Il y a une bonne habitude dans le fichier compose, et elle mérite d’être soulignée parce que c’est le correctif le moins cher de tous. La base publie son port comme ceci :

db:
  ports:
    - '127.0.0.1:5432:5432'   # loopback seulement, pas internet entier

Ce préfixe 127.0.0.1: compte énormément. Sans lui, - '5432:5432' dit à Docker de publier sur 0.0.0.0, toutes les interfaces, y compris l’interface publique. Avec lui, la règle DNAT de Docker ne matche que le trafic arrivé sur loopback, donc rien venant de l’extérieur de la machine ne peut l’atteindre. C’est ce binding qui a fermé le trou.

La partie honnête : le même fichier publie encore d’autres services sur toutes les interfaces.

api:
  ports: ['8000:8000']          # 0.0.0.0, exposé
storage:
  ports: ['9000:9000', '9001:9001']   # le stockage objet ET sa console d'admin, exposés

Donc la base est corrigée, mais l’API sur 8000 et la console d’admin MinIO sur 9001 sont encore publiées sur internet entier. Ce qui mène à la meilleure question.

Le vrai correctif : les services internes n’ont besoin d’aucun port publié

Publier un port, c’est pour le trafic qui vient de l’extérieur de la machine. Regardez ce qui en a vraiment besoin : rien, sauf nginx. Le reverse proxy joint déjà chaque autre service par son nom, via le réseau Docker :

# nginx parle aux conteneurs directement, par le réseau interne
proxy_pass http://api:8000;
proxy_pass http://storage:9000;

nginx n’utilise pas le 8000 ou le 9000 publiés sur l’hôte. Il utilise le DNS interne de Docker et le réseau partagé. Ce qui veut dire que les entrées ports: sur api, storage et le dashboard ne sont pas seulement risquées, elles sont inutiles. Rien en dehors du réseau compose ne les utilise.

Donc le correctif pour la plupart des services, c’est de supprimer la publication de port entièrement. Le seul conteneur qui devrait publier vers l’hôte, c’est nginx, sur 80 et 443. Tout le reste parle par le réseau interne, invisible de l’extérieur :

nginx:
  ports: ['80:80', '443:443']   # la seule surface publique
api:
  # aucun bloc ports:, nginx le joint via http://api:8000 en interne
db:
  ports: ['127.0.0.1:5432:5432'] # loopback seulement, pour psql/backups locaux

Si vous avez vraiment besoin d’un port joignable de l’extérieur mais restreint (disons Postgres depuis une seule IP de bureau), ne sortez pas ufw. Mettez la règle dans la chaîne DOCKER-USER, la seule chaîne iptables que Docker garantit d’évaluer avant ses propres règles et de ne jamais vider :

# seule cette source peut atteindre le trafic conteneur transféré sur 5432
iptables -I DOCKER-USER -p tcp --dport 5432 ! -s 203.0.113.10 -j DROP

Auditez depuis l’extérieur, ne croyez jamais iptables -L

La leçon sous tout ça : la config de votre pare-feu est une affirmation, pas un fait. iptables -L et ufw status vous montrent ce que vous avez voulu, à travers la chaîne que vous regardiez. Ils vous montreront joyeusement 5432 comme refusé sur INPUT alors qu’il est grand ouvert via FORWARD.

La seule source de vérité, c’est un scan depuis une machine qui n’est pas le serveur :

# depuis votre laptop, pas depuis la machine
nmap -Pn -p 5432,8000,9000,9001 votre.ip.serveur

Si autre chose que 80 et 443 revient en open, vous avez un service exposé que vous ne vouliez probablement pas exposer. Cette seule commande m’aurait parlé de la base des mois avant qu’un attaquant ne le fasse.

À retenir

Docker gère le pare-feu à votre place, et il ne consulte pas celui que vous avez écrit. Publier un port ajoute en silence des règles dans FORWARD et nat que vos règles INPUT et ufw ne touchent jamais, donc “le port est bloqué” et “le port est ouvert” peuvent être vrais en même temps. Bindez sur 127.0.0.1 tout ce que vous devez publier, ne publiez rien que nginx peut proxifier en interne, utilisez DOCKER-USER pour les vraies restrictions, et vérifiez avec un scan externe plutôt qu’une liste locale.

Fermer les ports n’est que la moitié de la sécurité périmétrique. L’autre moitié, c’est quoi faire du trafic encore autorisé à atteindre nginx : les scans et le bruteforce constants, et le jour où mon certificat TLS n’a pas su se renouveler. C’est un article plus loin dans cette série. La vue d’ensemble de la série suit tout ça.