Le montage suivant est courant dans les stacks auto-hébergées :
services:
outil:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:roLe suffixe :ro donne l’impression que le conteneur ne dispose que d’un accès d’observation à Docker. Ce n’est pas ce que cette option signifie.
Compose demande simplement au moteur de présenter le chemin /var/run/docker.sock dans un montage en lecture seule. Une fois la connexion au socket Unix établie, le client échange des requêtes HTTP avec l’API Engine. Le noyau ne traduit pas :ro en une politique du type « GET autorisé, POST et DELETE refusés ».
Sur un daemon Docker classique exécuté en root, un conteneur compromis qui peut joindre directement ce socket doit donc être traité comme un client d’administration Docker. Le risque dépend ensuite du rôle du service, de son exposition réseau, de sa propre sécurité et du type de daemon utilisé, mais :ro ne réduit pas les actions disponibles dans l’API.
En bref#
:rorend le bind mount non inscriptible. Il ne transforme pas l’API Docker en API de consultation.- Les permissions Unix décident si le processus peut se connecter. Une fois la connexion établie, le daemon ou un composant d’autorisation décide quelles requêtes sont admises.
docker inspectpeut afficherMode: "ro"etRW: falsealors qu’une requête HTTPPOSTtraverse correctement le socket.- Portainer, Dockge et Watchtower ont besoin d’opérations de gestion. Il faut les traiter comme des composants d’administration, même avec
:ro. - Traefik et les agents de supervision sont de meilleurs candidats pour la suppression du socket ou un proxy en refus par défaut. Ce proxy doit aussi limiter les données lisibles.
- Rootless réduit l’impact sur l’hôte sans créer d’autorisation par endpoint.
userns-remap, SSH et TLS répondent encore à d’autres problèmes.
Ce que :ro protège réellement#
La syntaxe courte de Compose définit trois éléments : une source sur l’hôte, une cible dans le conteneur et un mode d’accès. ro signifie que le montage est présenté en lecture seule. La syntaxe longue exprime la même chose avec read_only: true :
services:
outil:
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
read_only: trueDans l’API Moby, cet état est porté par le booléen Mount.ReadOnly. Après création du conteneur, docker inspect le restitue avec les champs Mode et RW :
{
"Type": "bind",
"Source": "/var/run/docker.sock",
"Destination": "/var/run/docker.sock",
"Mode": "ro",
"RW": false
}Pour un fichier de configuration classique, ce réglage empêche une application de tronquer ou de remplacer le contenu par l’intermédiaire de ce montage. Pour un socket, le scénario est différent.
Le chemin sert à localiser un endpoint IPC. Le client crée son propre descripteur de socket, appelle connect() sur /var/run/docker.sock, puis envoie une requête HTTP sur la connexion établie. Il n’ouvre pas le pseudo-fichier avec open(..., O_WRONLY) pour y enregistrer le corps de la requête.
Sous Linux, unix(7) indique qu’une connexion à un socket nommé requiert le droit d’écriture sur ce socket. Ce contrôle décide si connect() est autorisé. Il ne qualifie pas les données qui seront ensuite envoyées sur la connexion.
Cette distinction explique deux comportements parfois confondus :
- un processus sans les bons UID, GID, capacités ou droits LSM reçoit
Permission denied, même si le montage estrw; - un processus autorisé à se connecter peut envoyer des requêtes
GET,POSTouDELETE, même si le montage estro.
Rendre le système de fichiers racine du conteneur non inscriptible avec read_only: true ne change pas ce point. Les volumes, bind mounts et tmpfs conservent leur propre mode d’accès. Cette option durcit utilement le conteneur, mais n’interdit ni les connexions réseau ni les échanges sur un socket Unix déjà accessible.
L’API Engine est une API HTTP. La documentation Docker précise que les commandes du client correspondent, pour l’essentiel, à des endpoints de cette API. docker ps appelle par exemple GET /containers/json, tandis que les opérations de cycle de vie utilisent d’autres méthodes et chemins.
Le daemon reçoit une requête HTTP après la connexion. Il ne reçoit pas une information fiable lui indiquant que le client a découvert son socket à travers un bind mount ro. Même s’il la recevait, aucun contrat de l’API ne donne à ro le sens d’un rôle « lecture seule ».
Les deux couches ne prennent donc pas la même décision :
| Couche | Décision prise |
|---|---|
| Bind mount Compose | Le chemin monté peut-il subir des écritures de type système de fichiers ? |
| Permissions du socket Unix | Le processus peut-il établir la connexion locale ? |
| Proxy ou plugin d’autorisation | Cette méthode, ce chemin et éventuellement ce client sont-ils autorisés ? |
| Docker Engine | Quelle opération l’endpoint demandé doit-il exécuter ? |
Ajouter :ro intervient uniquement sur la première ligne.
Démonstration contrôlée avec curl --unix-socket#
Les premières requêtes peuvent être exécutées directement sur l’hôte. Elles ne modifient aucun objet Docker :
socket=/var/run/docker.sock
curl --silent --show-error --fail \
--unix-socket "$socket" \
http://localhost/_ping
api_version="$(
curl --silent --show-error --fail \
--unix-socket "$socket" \
http://localhost/version |
jq -r '.ApiVersion'
)"
curl --silent --show-error --fail \
--unix-socket "$socket" \
"http://localhost/v${api_version}/containers/json?all=1" |
jq -r '.[] | [.Names[0], .Image, .State, .Status] | @tsv'localhost n’implique ici aucune connexion TCP. Avec --unix-socket, curl utilise le socket Unix et emploie l’URL uniquement pour construire la requête HTTP.
Pour vérifier qu’une méthode POST passe également à travers un montage :ro sans toucher au cycle de vie d’un workload, l’endpoint POST /containers/{id}/wait est pratique. Il attend un état et retourne le code de sortie. Il ne démarre, n’arrête, ne crée ni ne supprime le conteneur ciblé.
Le test suivant utilise un conteneur déjà arrêté comme cible. Il crée uniquement un client curl éphémère, supprimé automatiquement à la fin :
Ce client reçoit temporairement un accès direct au daemon et, sans politique d’autorisation intermédiaire, des droits d’administration très larges. N’exécutez pas une image non approuvée contre un hôte de production. L’exemple épingle l’image vérifiée pendant le test, mais reste à réserver à un environnement de laboratoire ou à un daemon sans workload sensible. Le digest doit déjà être présent localement : --pull=never fait volontairement échouer la commande dans le cas contraire.
socket=/var/run/docker.sock
socket_gid="$(stat -Lc '%g' "$socket")"
test_image='curlimages/curl:8.14.1@sha256:9a1ed35addb45476afa911696297f8e115993df459278ed036182dd2cd22b67b'
api_version="$(
curl --silent --show-error --fail \
--unix-socket "$socket" \
http://localhost/version |
jq -r '.ApiVersion'
)"
wait_target="$(
docker --host "unix://$socket" \
ps -aq --filter status=exited |
head -n 1
)"
if [ -z "$wait_target" ]; then
printf '%s\n' 'Aucun conteneur arrêté : test POST ignoré.'
else
docker --host "unix://$socket" run --rm \
--pull=never \
--network none \
--read-only \
--group-add "$socket_gid" \
--volume "$socket:$socket:ro" \
"$test_image" \
--silent --show-error --fail \
--unix-socket "$socket" \
--request POST \
"http://localhost/v${api_version}/containers/${wait_target}/wait?condition=not-running"
fiSur le daemon rootful du laboratoire, sans userns-remap, --group-add donne au processus non-root de l’image curl le groupe numérique qui possède le socket. Il ne contourne pas :ro. Sans ce droit, la connexion échoue avant même d’atteindre l’API. Avec userns-remap, les correspondances d’UID et de GID changent et cet exemple n’est pas directement transposable.
Le test initial visible dans l’illustration a été exécuté sur Docker Engine 29.7.2, API 1.55. Il a été rejoué avant publication sur Docker Engine 29.8.0, API 1.56, avec un montage inspecté en Mode: "ro", RW: false et un rootfs lui-même en lecture seule. Dans les deux cas, le daemon a répondu en HTTP 200 à la requête POST. Le second test a retourné {"StatusCode":137} pour le conteneur arrêté choisi sur cette machine ; cette valeur dépend de la cible.
L’image curlimages/curl:8.14.1 épinglée par digest a été vérifiée et préchargée avant le test. --network none retire ensuite un accès réseau inutile. Les appels à /_ping, /version, /containers/json et /wait ont été vérifiés. En dehors du client curl éphémère, aucun workload n’a été créé, démarré, arrêté, modifié ou supprimé.
Modèle de menace et impact réel#
Le montage du socket n’est pas une vulnérabilité exploitable à distance à lui seul. Un attaquant doit d’abord contrôler un processus qui peut atteindre le socket, ou obtenir un autre accès autorisé au daemon.
| Situation | Prérequis | Impact réaliste |
|---|---|---|
| Conteneur sans socket ni endpoint Docker | Aucun chemin ni réseau vers l’API | Pas d’accès direct au daemon par ce mécanisme |
| Socket présent mais permissions refusées | UID, GID, capacités ou politique LSM incompatibles | Connexion bloquée avant l’API |
| Service de découverte compromis avec socket direct | Exécution de code dans le service et permission de connexion | Accès à toute l’API acceptée par le daemon, pas seulement aux besoins fonctionnels du service |
| Interface d’administration compromise | Session, jeton, faille applicative ou exposition réseau | Contrôle attendu de Docker, avec un périmètre souvent très large |
Utilisateur membre du groupe docker | Session locale ou clé SSH de cet utilisateur | Privilèges de niveau root sur un daemon rootful, comme l’indique Docker |
| API TCP sur 2375 sans TLS | Accès réseau au port | Contrôle non authentifié du daemon si aucun autre filtre ne protège l’endpoint |
| Socket d’un daemon rootless | Compromission d’un client autorisé | Contrôle du daemon rootless, de ses conteneurs et des ressources accessibles à son utilisateur |
Sur Linux avec un daemon rootful, la documentation Docker demande explicitement de ne confier son contrôle qu’à des utilisateurs de confiance. Un client autorisé peut piloter le cycle de vie des conteneurs, leurs montages, réseaux, volumes, images, logs et processus. Il faut traiter cette capacité comme un accès d’administration de l’hôte, même si le conteneur client lui-même fonctionne sans le mode privilégié.
Le risque pratique augmente lorsque le service qui possède le socket est :
- exposé directement sur Internet ;
- doté d’une interface d’administration faible ou mal segmentée ;
- extensible par des plugins ;
- mis à jour automatiquement sans contrôle ;
- capable de lire des fichiers de stack ou des identifiants de registre ;
- partagé entre plusieurs utilisateurs ou tenants ;
- déployé sur un manager Swarm, où l’API porte aussi des objets de cluster.
À l’inverse, un outil peu exposé, maintenu, fortement authentifié et isolé réduit la probabilité initiale de compromission. Il ne change pas le niveau de privilège obtenu après compromission. C’est précisément là que la confusion autour de :ro devient dangereuse.
Pour les conteneurs Linux sous Docker Desktop, le daemon fonctionne dans une VM Linux. La frontière n’est donc pas identique à celle d’un Docker Engine rootful installé directement sur Linux. Le contrôle de tous les workloads, volumes et ressources partagées du daemon reste néanmoins un incident sérieux.
Vérifier son exposition#
Trouver les montages directs et indirects#
L’état d’exécution fait foi. Un fichier Compose peut avoir été surchargé, fusionné ou ne plus correspondre au conteneur actif.
Cette commande liste les montages directs de docker.sock, mais aussi les montages larges de /run, /var/run, /var ou / qui peuvent exposer le socket sous un autre chemin :
printf 'CONTENEUR\tSOURCE\tCIBLE\tACCES\tMODE\n'
docker ps -aq |
xargs -r docker inspect |
jq -r '
.[] as $container
| $container.Mounts[]?
| select(
.Type == "bind"
and (
.Source == "/"
or .Source == "/var"
or .Source == "/run"
or .Source == "/var/run"
or (.Source | test("(^|/)docker\\.sock$"))
)
)
| [
($container.Name | ltrimstr("/")),
.Source,
.Destination,
(if .RW then "rw" else "ro" end),
(.Mode // "")
]
| @tsv
'ACCES=ro confirme seulement le mode du montage. La ligne reste une exposition potentielle au daemon. Pour un montage de répertoire ou de la racine, vérifiez le chemin réel du socket vu depuis le conteneur avant de conclure qu’il est utilisé.
Le champ RW décrit le montage principal tel que Docker l’a créé, pas nécessairement tous ses sous-montages. Docker précise que la propagation récursive du mode lecture seule exige un noyau Linux 5.12 ou plus récent. Cette limite importe surtout pour les montages larges de répertoires ; elle ne transforme de toute façon pas le mode ro en filtrage de l’API.
La recherche dans les fichiers reste utile pour retrouver la source de la configuration. Adaptez les deux répertoires à votre organisation :
rg -n \
--glob '*compose*.yaml' \
--glob '*compose*.yml' \
'docker\.sock|DOCKER_HOST|2375|2376' \
/opt/stacks /srv/stacksPensez aussi aux sockets rootless sous /run/user/<uid>/docker.sock, aux chemins personnalisés passés à dockerd -H et aux proxies présentés sous un autre nom.
Vérifier les permissions et le groupe docker#
stat -Lc '%A %a %U:%G %n' /var/run/docker.sock
getent group docker
id
id nom_utilisateurUne sortie classique pour le socket rootful est srw-rw---- 660 root:docker. getent group docker et id nom_utilisateur interrogent les sources NSS configurées pour le compte. id sans opérande montre les groupes actifs du processus courant. Il faut rouvrir la session après un changement de groupes pour que ses groupes supplémentaires soient recalculés.
Le groupe peut porter un autre nom ou GID. Utilisez alors le propriétaire retourné par stat. N’élargissez pas le socket en 0666 pour corriger un problème de permission : cela donnerait l’accès au daemon à tous les processus locaux capables d’atteindre le chemin.
Pour un daemon rootless, vérifiez le socket du compte concerné :
rootless_socket="/run/user/$(id -u)/docker.sock"
stat -Lc '%A %a %U:%G %n' "$rootless_socket"
docker --host "unix://$rootless_socket" \
info --format '{{json .SecurityOptions}}' |
jqExécutez ces commandes avec le compte qui porte le daemon, ou remplacez $(id -u) par son UID. La présence de name=rootless dans les options de sécurité confirme le mode côté serveur interrogé. Le simple fait d’utiliser un chemin sous /run/user/ n’est pas une preuve suffisante.
Détecter les ports 2375 et 2376#
sudo ss -lntp 'sport = :2375 or sport = :2376'
sudo systemctl show -p ExecStart docker.service
if sudo test -f /etc/docker/daemon.json; then
sudo jq '{hosts, tls, tlsverify, tlscacert, tlscert, tlskey}' \
/etc/docker/daemon.json
fiLe port 2375 correspond conventionnellement à Docker sans TLS. Le port 2376 est utilisé avec TLS, mais le numéro de port ne prouve pas que la vérification des certificats clients est active. Contrôlez la configuration de dockerd, le processus réellement en écoute, l’adresse liée et les règles réseau.
Docker a déprécié les connexions TCP distantes non authentifiées. Depuis la version 27.0, dockerd refuse de démarrer lorsqu’une écoute TCP distante est combinée à --tls=false, --tlsverify=false ou aux options équivalentes dans daemon.json ; tcp://localhost fait exception. Sur un hôte moderne, un processus exposé sur 0.0.0.0:2375 peut donc être un ancien daemon, un socket proxy ou un autre service. Identifiez le processus avant de conclure à la configuration utilisée.
Une écoute sur 127.0.0.1:2375 reste joignable par des processus de l’hôte et peut devenir accessible à des conteneurs selon leur mode réseau. Une écoute sur 0.0.0.0:2375 ou [::]:2375 demande une correction prioritaire.
Vérifiez enfin les contextes du client afin de ne pas auditer le mauvais daemon :
docker context ls
docker context inspect "$(docker context show)" \
--format '{{json .Endpoints.docker.Host}}'Détection et limites de l’attribution#
Docker expose un flux d’événements utile pour rechercher des opérations récentes :
docker events \
--since 24h \
--until "$(date --iso-8601=seconds)" \
--format json |
jq -r '
select(.Type == "container")
| (.Action | split(":")[0]) as $action
| select(
$action == "create"
or $action == "commit"
or $action == "start"
or $action == "stop"
or $action == "kill"
or $action == "die"
or $action == "restart"
or $action == "destroy"
or $action == "pause"
or $action == "unpause"
or $action == "rename"
or $action == "update"
or $action == "oom"
or $action == "exec_create"
or $action == "exec_start"
or $action == "exec_die"
or $action == "export"
)
| [
(.time | todateiso8601),
.Action,
(.Actor.Attributes.name // ""),
(.Actor.Attributes.image // "")
]
| @tsv
'Les logs du daemon peuvent compléter l’analyse :
sudo journalctl \
-u docker.service \
--since '24 hours ago' \
--no-pagerCette sélection cible les changements de cycle de vie, les exécutions et quelques opérations sensibles. Elle n’est pas exhaustive. Ces données ne constituent pas non plus un journal d’audit complet : Docker ne conserve que les 256 derniers événements dans un buffer en mémoire, perdu au redémarrage du daemon. --since 24h fixe donc une borne de recherche, pas une garantie de disposer de 24 heures d’historique. Les événements décrivent surtout les objets et actions, pas l’identité fiable du processus qui a écrit sur un socket Unix partagé. Les logs standards ne consignent pas nécessairement chaque appel API.
Pour obtenir une attribution exploitable, il faut placer un point de contrôle qui journalise les requêtes, utiliser des identités TLS distinctes avec un plugin d’autorisation, ou collecter les actions au niveau de l’outil d’administration. Cette journalisation doit être envoyée hors de l’hôte si elle doit survivre à une compromission locale.
Portainer, Traefik, Watchtower, Dockge et supervision#
Tous ces outils peuvent monter le même socket, mais ils n’ont pas le même besoin fonctionnel.
| Outil | Besoin réel | Mitigation adaptée |
|---|---|---|
| Portainer | Administration complète : création, démarrage, arrêt, suppression, console, volumes et réseaux | Le traiter comme un plan d’administration. Restreindre l’accès réseau et les comptes ; préférer l’Agent ou l’Edge Agent pour le distant, sans croire que cela retire le privilège local de l’agent |
| Traefik | Découverte, inspection et événements Docker pour construire la configuration dynamique | Proxy avec liste minimale d’endpoints, réseau privé dédié, ou provider fichier si la découverte Docker n’est pas nécessaire |
| Watchtower | Inspection, téléchargement d’images, arrêt et recréation des conteneurs suivis | Un proxy sans méthodes qui modifient l’état empêche sa fonction principale. Supprimer l’automatisation si elle n’est pas assumée, ou isoler Watchtower comme un composant de déploiement hautement privilégié |
| Dockge | Exécution de Docker Compose et écriture des fichiers de stacks | Le traiter comme une console d’administration. Protéger aussi le répertoire de stacks, qui offre un autre chemin de modification persistante |
| Supervision | Souvent liste, inspection, statistiques et événements | Proxy limité, exporter dédié ou collecte cgroup sans accès brut à toute l’API quand c’est possible |
Portainer#
La procédure officielle d’installation locale monte le socket sans :ro, ce qui est cohérent avec le produit : Portainer sait créer, démarrer, arrêter, supprimer et ouvrir une console sur des conteneurs. Le suffixe :ro ne retire aucune de ces fonctions puisqu’elles passent par l’API.
Un socket proxy très restrictif convient mal à une interface qui promet l’administration complète. Ouvrir progressivement tous les endpoints nécessaires finit souvent par restituer un accès presque total. Pour des environnements distants, l’Edge Agent chiffre le tunnel et évite d’exposer directement l’agent sur Internet, mais l’agent installé sur le nœud doit toujours disposer des droits locaux nécessaires.
La bonne frontière est donc l’accès à Portainer lui-même : réseau d’administration, authentification forte, comptes nominatifs, rôles minimaux, mises à jour maîtrisées et sauvegarde protégée de ses données.
Traefik#
Traefik documente explicitement le risque d’un accès Docker sans restriction. Son provider Docker a principalement besoin de découvrir les objets, de les inspecter et de suivre les événements. C’est un cas raisonnable pour un proxy en refus par défaut.
exposedByDefault=false reste recommandé pour le routage, mais n’est pas une règle d’autorisation de l’API Engine. Cette option dit à Traefik quels conteneurs publier ; elle n’empêche pas le processus Traefik compromis d’interroger directement d’autres endpoints accessibles.
Si les routes changent peu, le provider fichier retire entièrement cette dépendance au socket. Cela coûte un rechargement de configuration explicite, mais crée une frontière plus simple à auditer.
Watchtower#
Watchtower ne se contente pas d’observer les tags d’images. Lorsqu’une mise à jour est appliquée, il télécharge l’image, arrête l’ancien conteneur et le recrée avec sa configuration. Ces opérations exigent des endpoints qui modifient l’état.
Le dépôt historique containrrr/watchtower a annoncé son archivage fin 2025. Le comportement décrit ici correspond au fork nickfedor/watchtower cité dans les sources. Adopter ce fork reste un changement de fournisseur à évaluer, pas une continuité implicite de confiance.
Un montage :ro qui laisse Watchtower fonctionner normalement démontre justement que ce mode ne filtre pas l’API. À l’inverse, un proxy configuré avec POST=0 ne peut pas fournir une mise à jour complète. Autoriser les opérations nécessaires revient à confier à Watchtower une part importante du plan de contrôle.
Dans un environnement où les changements passent déjà par Git et une fenêtre de maintenance, une mise à jour déclenchée depuis la CI ou par un opérateur est souvent plus facile à tracer et à restaurer.
Dockge#
Dockge gère des stacks Compose, sait les créer, les éditer, les démarrer, les arrêter et mettre à jour leurs images. Son installation officielle monte le socket et le répertoire des stacks.
Le risque ne se limite donc pas à Docker. Un attaquant qui contrôle Dockge peut potentiellement modifier les fichiers Compose montés en écriture, puis attendre ou provoquer un redéploiement. Passer uniquement le socket en :ro laisse ces deux capacités de gestion en place.
Dockge doit être exposé comme une interface d’administration, pas comme une application web ordinaire. Un daemon rootless dédié à un ensemble de stacks non critiques peut réduire le périmètre, à condition que ces stacks n’aient pas besoin du daemon rootful voisin.
Agents de supervision#
Telegraf et d’autres agents peuvent utiliser l’API pour obtenir noms, labels, états et statistiques. L’usage semble passif, mais l’accès direct au socket ne l’est pas. La documentation Telegraf avertit d’ailleurs que cet accès étend la surface jusqu’à un possible contrôle root de la machine.
Même un proxy limité à GET ne garantit pas la confidentialité. Selon les préfixes autorisés, l’API peut retourner la configuration inspectée, les variables d’environnement, les logs, les informations de processus ou des archives de systèmes de fichiers de conteneurs. Il réduit les modifications ordinaires, mais ne garantit pas non plus l’intégrité si un endpoint GET ouvre ensuite un canal bidirectionnel.
Lorsque seules des métriques sont nécessaires, préférez un exporter qui publie un schéma borné vers Prometheus plutôt qu’un accès brut de chaque consommateur à l’API Docker.
Limites des socket proxies#
Un socket proxy agit au bon niveau : il voit la méthode et le chemin HTTP avant de transmettre la requête au daemon. C’est une vraie amélioration par rapport à :ro, sous plusieurs conditions.
- Le proxy devient le seul conteneur qui monte le socket réel. Il appartient donc à la base de confiance.
- Son port ne doit pas être publié sur le LAN ou Internet. Un réseau Docker interne partagé uniquement avec le client est préférable.
- La politique doit être en refus par défaut et adaptée à une application, pas mutualisée sans nécessité entre tous les outils.
- Les règles par préfixe comme
CONTAINERS=1peuvent autoriser beaucoup plus queGET /containers/jsonetGET /containers/{id}/json. GETn’est pas toujours synonyme d’observation. Docker expose par exempleGET /containers/{id}/attach/ws, qui peut établir un WebSocket et transporter l’entrée standard d’un conteneur configuré pour l’accepter.- Bloquer
POSTetDELETEréduit les modifications, mais des endpointsGETpeuvent encore exposer des données sensibles. - Autoriser la création de conteneurs, l’exécution de commandes, les montages ou les services rétablit une capacité d’administration très large.
- Les changements de version de l’API et de l’application peuvent ajouter de nouveaux appels. Il faut vérifier les logs de refus après chaque mise à jour.
- Un simple proxy TCP sans compréhension HTTP ne filtre rien. Il ne fait que déplacer le socket.
Docker fournit aussi un mécanisme de plugins d’autorisation. Il permet de décider sur le contexte du client et de la commande directement dans le chemin de traitement du daemon. C’est plus structurant qu’un proxy de convenance, mais aussi plus complexe à exploiter et à rendre hautement disponible.
Exemple de réduction de surface pour Traefik#
Le fragment suivant illustre une réduction pragmatique de la surface avec LinuxServer Socket Proxy. Ce n’est ni une liste exacte d’URL ni une frontière d’intégrité stricte. Ce n’est pas non plus une stack Traefik complète : conservez l’image, les ports, les autres options de commande et les réseaux applicatifs de votre déploiement. Il n’est volontairement pas adapté à Portainer, Dockge ou Watchtower, qui ont besoin d’opérations de gestion.
services:
socket-proxy:
image: lscr.io/linuxserver/socket-proxy@sha256:cba12c42b1df30a6446856028f5ab726e9d8921f1ea74be509216c5e41357d42
restart: unless-stopped
environment:
CONTAINERS: "1"
EVENTS: "1"
PING: "1"
VERSION: "1"
POST: "0"
ALLOW_ARCHIVE: "0"
ALLOW_CHANGES: "0"
ALLOW_EXPORT: "0"
ALLOW_LOGS: "0"
ALLOW_PAUSE: "0"
ALLOW_RESTARTS: "0"
ALLOW_START: "0"
ALLOW_STOP: "0"
ALLOW_TOP: "0"
ALLOW_UNPAUSE: "0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
read_only: true
tmpfs:
- /run
networks:
- docker-api
traefik:
command:
- --providers.docker.endpoint=tcp://socket-proxy:2375
networks:
- docker-api
# Conserver ici les réseaux applicatifs nécessaires à Traefik.
networks:
docker-api:
internal: trueLe digest multi-architecture correspond à LinuxServer Socket Proxy 3.4.4-r0-ls96 et a été revérifié avant publication. Il rend cet exemple reproductible, mais bloque aussi les correctifs futurs. Une procédure de mise à jour doit remplacer le digest après revue.
Il n’y a aucun ports: sur le proxy : le port 2375 n’est pas publié sur une interface de l’hôte. internal: true isole le réseau des réseaux externes, mais ne constitue pas un pare-feu vis-à-vis de l’hôte Docker. Tout autre conteneur attaché à docker-api peut joindre le proxy ; il faut donc réserver ce réseau aux deux services concernés. POST=0 refuse par défaut tout sauf GET et HEAD, mais les options ALLOW_* du proxy peuvent rouvrir certains chemins malgré cette règle. Les exceptions qui modifient l’état sont donc explicitement désactivées dans l’exemple.
CONTAINERS=1 reste une permission large sur la famille /containers. Les sous-chemins logs, archive, changes, export et top sont désactivés séparément, mais l’inspection d’un conteneur révèle encore ses variables d’environnement, ses labels et ses montages. Les métadonnées Docker doivent donc être considérées comme accessibles à Traefik.
Cette permission laisse également passer GET /containers/{id}/attach/ws. Avec stdin=true et un conteneur configuré pour accepter cette entrée, l’endpoint peut devenir bidirectionnel après la mise à niveau WebSocket. Si la politique doit garantir qu’aucune entrée ne puisse atteindre un conteneur, utilisez un proxy capable d’autoriser des couples méthode-chemin exacts et de refuser explicitement les connexions mises à niveau, ou une politique d’autorisation équivalente.
Cette politique a été testée sur Docker Engine 29.8.0 et l’API 1.56. GET /_ping et GET /containers/json ont retourné 200. POST /containers/{id}/wait, GET /containers/{id}/logs et GET /containers/{id}/archive ont été refusés par le proxy avec 403. Une requête vers GET /containers/{id}/attach/ws a atteint Docker et retourné 404 pour la cible inexistante, ce qui confirme la limite. Le conteneur proxy et son réseau de laboratoire ont ensuite été supprimés.
Pour Swarm, les endpoints nécessaires diffèrent et incluent notamment services, tâches, nœuds et réseaux. Ne recopiez pas une liste d’autorisations Docker Standalone sur un manager sans observer les appels réellement effectués.
Rootless et userns-remap ne résolvent pas le même problème#
Rootless#
En mode rootless, le daemon et les conteneurs fonctionnent sans privilèges root sur l’hôte, dans un espace de noms utilisateur. Le socket se trouve généralement sous /run/user/<uid>/docker.sock.
Le gain est réel : une compromission du daemon ou un usage abusif de son API n’a pas directement les privilèges du root hôte. La limite reste nette : par défaut et sans plugin d’autorisation, le détenteur du socket contrôle tous les conteneurs de ce daemon, leurs données Docker et tout ce que l’utilisateur du daemon peut lire ou modifier sur l’hôte.
Rootless convient bien à un daemon dédié à des workloads qui acceptent ses contraintes. Il ne transforme pas son API en contrôle d’accès fin, et il ne protège pas un daemon rootful distinct qui resterait exposé par ailleurs.
userns-remap#
Avec userns-remap, le root du conteneur est mappé vers un UID non privilégié sur l’hôte. Cela peut empêcher naturellement un conteneur d’ouvrir le socket root:docker, ce qui est utile.
Mais le daemon lui-même continue de fonctionner en root. Si l’accès est rétabli par des permissions accordées à un identifiant remappé, ou si le remapping est désactivé pour ce service, les requêtes sont toujours exécutées par ce daemon root. userns-remap limite certains effets d’une compromission de conteneur ; il ne crée pas de rôles dans l’API Engine.
Les bind mounts demandent en outre une préparation des propriétaires et permissions, ce que la documentation Docker cite parmi les complications connues de ce mode.
Mitigations adaptées#
1. Supprimer le socket quand il n’est pas indispensable#
La meilleure correction reste de retirer le montage et toute variable DOCKER_HOST pointant vers le daemon.
- Pour Traefik, envisagez le provider fichier.
- Pour la supervision, exposez des métriques bornées depuis un exporter.
- Pour un dashboard purement informatif, alimentez-le depuis Prometheus ou une API intermédiaire.
- Pour les mises à jour, déclenchez le déploiement depuis un hôte d’administration ou une CI séparée.
Un service qui ne peut plus joindre l’API n’a plus besoin d’une politique parfaite autour de cette API.
2. Interposer un proxy avec des endpoints limités#
Cette option convient aux outils de découverte et de supervision. Commencez avec /_ping et /version, puis ajoutez uniquement les chemins observés comme nécessaires.
Séparez les politiques : un proxy pour Traefik, un autre pour la supervision si leurs besoins diffèrent. Cela évite qu’une permission ajoutée pour un outil soit héritée par tous les autres clients du même réseau.
Le proxy doit être épinglé par version ou digest, mis à jour, journalisé et isolé. Son propre montage :ro reste une mesure de durcissement du conteneur proxy, pas la règle d’autorisation. La règle utile est celle appliquée aux requêtes HTTP.
3. Utiliser un daemon rootless dédié#
Pour Dockge, un outil de développement ou un petit ensemble de services, un daemon rootless dédié peut réduire le rayon d’impact. Vérifiez avant migration les ports privilégiés, les cgroups, les drivers de stockage, les bind mounts et les intégrations qui attendent le socket rootful.
Ne laissez pas simultanément le service accéder au socket rootless et à /var/run/docker.sock, faute de quoi le gain disparaît.
4. Utiliser SSH pour l’administration distante#
Docker sait créer un contexte qui transporte les commandes par SSH :
docker context create production \
--docker host=ssh://docker-admin@docker.example.net
docker --context production versionSSH évite d’ouvrir l’API Docker en clair sur le réseau et réutilise l’authentification ainsi que les journaux de connexion du serveur SSH. Ces journaux ne détaillent pas nécessairement chaque action Docker. Le compte distant doit toujours avoir le droit d’accéder au socket. Sur un daemon rootful, ce compte reste donc hautement privilégié.
Cette solution convient à un opérateur ou une CI distante. Elle ne réduit pas le risque d’un conteneur local auquel on monterait quand même le socket.
5. Utiliser TLS mutuel pour une API distante#
Si un client doit joindre Docker directement en TCP, activez tlsverify, utilisez une autorité dédiée et protégez les clés clientes. La vérification peut être testée sans afficher les clés :
docker \
--host tcp://docker.example.net:2376 \
--tlsverify \
--tlscacert /chemin/protege/ca.pem \
--tlscert /chemin/protege/client-cert.pem \
--tlskey /chemin/protege/client-key.pem \
versionTLS authentifie et chiffre. Par défaut, un certificat client accepté peut toujours transmettre toute commande admise par le daemon. Associez TLS à un plugin d’autorisation si plusieurs identités doivent recevoir des droits différents.
N’exposez pas 2375 pour simplifier un déploiement. Un VPN ou un pare-feu est une couche complémentaire, pas un remplacement de l’authentification mutuelle.
Procédure de vérification après correction#
Une correction n’est terminée que lorsque l’état d’exécution et le comportement correspondent à la politique prévue.
1. Valider puis recréer uniquement le service concerné#
Depuis le répertoire de la stack :
docker compose config --quiet
docker compose up -d --no-deps --force-recreate nom_du_service
docker compose ps nom_du_serviceLa recréation interrompt brièvement le service selon son architecture. Remplacez nom_du_service par la cible réelle et planifiez l’opération si elle porte du trafic.
2. Refaire l’inventaire des montages#
Relancez la commande basée sur docker inspect. Le service corrigé ne doit plus présenter docker.sock, /run, /var/run, /var ou / d’une manière qui rende le socket accessible.
Si un proxy est utilisé, lui seul doit conserver le socket réel. Les clients doivent pointer vers son nom sur un réseau privé et ne doivent pas partager ce réseau avec des workloads sans rapport.
3. Tester l’autorisation du proxy sans modifier Docker#
Depuis un client de diagnostic placé sur le même réseau privé :
curl --silent --show-error --fail \
http://socket-proxy:2375/_ping
api_version="$(
curl --silent --show-error --fail \
http://socket-proxy:2375/version |
jq -r '.ApiVersion'
)"
curl --silent --output /dev/null \
--write-out '%{http_code}\n' \
--max-time 5 \
--request POST \
"http://socket-proxy:2375/v${api_version}/containers/cible-inexistante/wait?condition=not-running"Le premier appel doit réussir. Avec la politique de l’exemple, le second doit être refusé par le proxy, généralement avec 403. Une réponse 404 provenant de Docker indique que la requête a atteint le daemon et que le filtre n’a pas bloqué ce chemin ou cette méthode.
L’endpoint wait ne modifie pas l’état d’un conteneur. Le délai évite en outre de laisser la commande bloquée si une cible de ce nom apparaissait malgré tout.
4. Recontrôler les ports et les groupes#
sudo ss -lntp 'sport = :2375 or sport = :2376'
getent group dockerSupprimez les membres devenus inutiles du groupe docker, puis ouvrez une nouvelle session avant de vérifier avec id. Si 2376 est attendu, testez la validation TLS depuis un client autorisé et confirmez qu’une connexion sans certificat est refusée.
5. Vérifier la fonction métier#
- Traefik doit encore recevoir les événements et router un service de test prévu à cet effet.
- La supervision doit retrouver ses cibles et continuer à collecter les métriques attendues.
- Portainer ou Dockge doivent être testés depuis le réseau d’administration, avec un compte non administrateur lorsque le rôle le permet.
- Watchtower doit être vérifié en mode d’observation ou sur une cible de laboratoire avant toute fenêtre de mise à jour.
Consultez ensuite les logs du client et du proxy. Un endpoint refusé en boucle signale souvent une politique incomplète ; l’ouvrir sans analyser sa portée recrée progressivement l’accès total que le proxy devait éviter.
Périmètre des tests#
Les commandes d’inventaire, les contrôles du socket et des ports, les requêtes curl en GET et POST, ainsi que la politique du proxy ont été rejoués avant publication sur Docker Engine 29.8.0 avec l’API 1.56. Le test direct d’origine et l’illustration ont été produits sur Docker Engine 29.7.2 avec l’API 1.55.
La migration vers rootless ou userns-remap, les contextes SSH, la mise en place d’une PKI TLS et la recréation des services Portainer, Traefik, Watchtower ou Dockge n’ont pas été exécutés sur cet hôte. Ces opérations dépendent de l’infrastructure cible et peuvent interrompre des services. Les commandes correspondantes reprennent la syntaxe officielle et doivent être validées dans un environnement de test adapté.
Conclusion#
/var/run/docker.sock:ro n’est pas inutile. Il empêche certaines mutations du point de montage et documente une intention de durcissement. Il ne répond simplement pas au problème d’autorisation de l’API Docker.
La frontière réelle est la capacité à appeler le daemon. Tant qu’un processus peut se connecter directement au socket d’un Docker Engine rootful sans être restreint par un plugin d’autorisation, il faut le considérer comme un client d’administration, quelle que soit la valeur de RW affichée par docker inspect.
Le choix opérationnel dépend du besoin : supprimer le socket pour les services qui peuvent s’en passer, filtrer précisément les endpoints pour la découverte, assumer un plan d’administration fortement protégé pour Portainer ou Dockge, et réduire le rayon d’impact avec rootless lorsque l’architecture le permet.
Le contrôle final n’est pas de relire compose.yaml. Il consiste à inspecter les montages actifs, vérifier les permissions et les écoutes réseau, puis démontrer qu’une requête autorisée passe et qu’une requête interdite est bloquée avant d’atteindre Docker.
Sources#
- Docker : bind mounts et option read-only
- Compose Specification : volumes d’un service
- Docker Engine API
- Docker Engine API v1.56 : référence des endpoints
- Docker Engine security et surface d’attaque du daemon
- Docker : protéger le socket du daemon avec SSH ou TLS
- Docker : accès distant au daemon et ports 2375/2376
- Docker : dépréciation des connexions TCP distantes sans authentification
- Docker : le groupe docker accorde des privilèges de niveau root
- Docker : mode rootless
- Docker : isolation avec userns-remap
- Docker : plugins d’autorisation de l’API
- Docker Desktop : conteneurs exécutés dans la VM Linux
- Moby 29.7.2 : structure Mount et booléen ReadOnly
- Moby 29.8.0 : buffer des événements du daemon
- Linux man-pages : permissions des sockets Unix nommés
- curlimages/curl : image conteneur officielle de curl
- Portainer : installation sur Docker avec le socket local
- Portainer : opérations disponibles sur les conteneurs
- Portainer : architecture Agent et Edge Agent
- Traefik : provider Docker et note de sécurité sur l’accès à l’API
- Fork nickfedor/watchtower : fonctionnement et montage du socket Docker
- Projet historique containrrr/watchtower : annonce d’archivage
- Dockge : fonctions et configuration officielle
- LinuxServer Socket Proxy : règles et précautions d’exploitation
- LinuxServer Socket Proxy 3.4.4-r0-ls96 : règles HAProxy par méthode et préfixe
- Telegraf : plugin Docker et avertissement de sécurité
- Docker : événements du daemon et limite des 256 événements




