J'ai donné les clés de la machine à mon reverse proxy
Traefik, l'auto-discovery Docker, et une ligne de configuration recopiée de la doc officielle qui ouvrait une escalade de privilèges complète vers l'hôte.
Sur mon homelab, ma méthode était simple et un peu honteuse : un service, un port. Jellyfin sur 8096, Grafana sur 3000, et une redirection de port par service sur la box.
Ça marche. Ça ne passe pas à l'échelle, et surtout chaque port ouvert est un service exposé en direct, avec sa propre gestion du TLS (ou son absence) et ses propres failles.
Un reverse proxy inverse la logique : un seul point d'entrée en 443, qui lit le nom de domaine demandé et route vers le bon conteneur en interne.
Host et route vers le bon conteneur, sans qu'aucun port applicatif ne soit publié.Pourquoi Traefik et pas Nginx
Nginx aurait fait le boulot. Mais dans un environnement où les services sont des conteneurs qu'on déploie et redéploie sans arrêt, écrire un bloc server à la main à chaque fois devient pénible, et on finit par oublier d'en mettre un à jour.
Traefik lit sa configuration dans les labels des conteneurs. Le service déclare lui-même comment il veut être exposé :
services:
outline:
image: outlinewiki/outline
networks: [proxy]
labels:
- "traefik.enable=true"
- "traefik.http.routers.outline.rule=Host(`docs.exemple.fr`)"
- "traefik.http.routers.outline.entrypoints=websecure"
- "traefik.http.routers.outline.tls.certresolver=letsencrypt"
- "traefik.http.services.outline.loadbalancer.server.port=3000"
Aucun port publié vers l'hôte, aucune ligne à toucher dans le pare-feu. Le conteneur rejoint le réseau proxy, Traefik le voit apparaître, et le service est en HTTPS.
C'est élégant. Et c'est là que je me suis planté.
La ligne du problème
Pour lire les labels, Traefik a besoin de parler au démon Docker. La doc officielle donne la solution évidente :
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Je l'ai copiée sans réfléchir. Ça marchait, je suis passé à la suite.
C'est l'encadrant qui a tiqué en relisant mon compose. Sa question : si quelqu'un exécute du code dans ce conteneur, il peut faire quoi ?
Je n'avais pas la réponse. On l'a cherchée ensemble, et elle est brutale. Le socket Docker donne un contrôle total sur l'hôte. Qui peut parler au démon Docker peut lancer un conteneur privilégié, monter le système de fichiers de la machine hôte, et sortir de l'isolation.
Autrement dit, j'avais donné les clés de la machine au seul composant directement exposé sur Internet. Une faille dans Traefik, et on passait de la DMZ à l'hôte en une commande.
La correction
Deux choses.
D'abord un proxy de socket. Au lieu du socket brut, Traefik parle à un intermédiaire qui ne laisse passer que les appels de lecture dont il a besoin :
socket-proxy:
image: tecnativa/docker-socket-proxy
environment:
CONTAINERS: 1 # autorisé : lister et inspecter
NETWORKS: 1
POST: 0 # refusé : toute écriture
EXEC: 0
IMAGES: 0
Ensuite, deux instances de Traefik au lieu d'une. Une en DMZ, une en zone serveur. Celle qui est exposée sur Internet ne connaît que les conteneurs de la DMZ, et n'a aucune visibilité sur les services internes.
Ce que ça m'a appris
J'avais appliqué une configuration issue de la documentation officielle. Elle fonctionnait parfaitement. Et elle introduisait une escalade de privilèges complète.
La doc d'un outil explique comment le faire marcher. Elle explique rarement comment le faire marcher sans se tirer une balle dans le pied, parce que ce n'est pas son objectif. Son objectif, c'est que tu réussisses ton premier démarrage.
Depuis, j'ai un réflexe systématique : dès qu'un exemple de config comporte un montage de volume, une capacité ou un accès réseau particulier, je me demande ce qu'un attaquant en ferait s'il contrôlait le conteneur. Pour le socket Docker, la réponse est courte. Tout.
C'est aussi ce qui m'a poussé à scanner les images avec Trivy, mais j'en reparlerai.