Docker aggira UFW: perché e come risolvere
Docker usa regole iptables che bypassano UFW: una porta negata può restare accessibile da Internet. Scopri il meccanismo e le correzioni efficaci.
Perché Docker aggira UFW
Docker aggira UFW perché le porte dei container pubblicate non attraversano mai le regole del firewall gestite da UFW. Quando si esegue docker run -p 8080:80, Docker scrive una regola DNAT (destination network address translation) nella propria catena DOCKER della tabella nat del kernel. La catena PREROUTING esegue un salto verso questa catena. La regola riscrive la destinazione di ogni pacchetto indirizzandola all'indirizzo privato del container, prima che il kernel determini la destinazione del pacchetto. Il pacchetto riscritto viene quindi inoltrato al container attraverso la catena FORWARD, controllata da Docker. Le regole di UFW risiedono nella catena INPUT e il pacchetto non vi entra mai. Di conseguenza, ufw status mostra il rifiuto predefinito, sudo ufw deny 8080 restituisce un esito positivo e la porta 8080 continua a rispondere a tutta Internet.
Non si tratta di un bug di Docker e UFW non è guasto. Entrambi gli strumenti configurano lo stesso firewall del kernel. Le regole di Docker agiscono semplicemente in una fase precedente del percorso del pacchetto, quindi UFW non viene mai coinvolto. Questa guida dimostra l'aggiramento, ne spiega il meccanismo e illustra quindi le due correzioni efficaci: pubblicare le porte su 127.0.0.1 e applicare il filtraggio nella catena DOCKER-USER. Se UFW è una novità, configurarlo prima seguendo la guida di base sul firewall UFW, perché un firewall con rifiuto predefinito resta la base corretta per tutto il resto del server.
Verificare il bypass sul proprio server
Partire da un VPS con UFW attivo e una policy predefinita che nega il traffico in ingresso. Avviare un container web con una porta pubblicata:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose mostra Default: deny (incoming), allow (outgoing) e nessuna regola per la porta 8080. Secondo il report del firewall, la porta è chiusa. Eseguire ora il test da un'altra macchina, non dal server stesso:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKIl container risponde. Aggiungere una regola di deny esplicita ed eseguire di nuovo il test:
sudo ufw deny 8080/tcpLa porta continua a rispondere perché la regola di deny si trova in una chain che il pacchetto non attraversa mai. UFW non ha avuto problemi. Non è mai stato consultato. Per questo il problema è difficile da individuare: non viene stampato alcun errore, il deploy ha esito positivo e l'output dello stato del firewall appare identico a quello di un server correttamente protetto.
Il meccanismo: PREROUTING viene eseguita prima di INPUT
Il kernel elabora i pacchetti in ingresso secondo un ordine prestabilito, e l'intero problema dipende da questo ordine.
PREROUTINGviene eseguita per prima. Le regole raggiunte da questo punto possono riscrivere la destinazione del pacchetto. La regola di Docker per una porta pubblicata, nella catenaDOCKERa cuiPREROUTINGtrasferisce l'elaborazione, fa esattamente questo.- Segue la decisione di routing. Un pacchetto indirizzato allo stesso host passa alla catena
INPUT. Un pacchetto indirizzato a un'altra macchina passa alla catenaFORWARD. - Le regole di UFW si trovano in
INPUT. Le regole di Docker si trovano inFORWARD.
Esamina la regola di Docker per il container appena avviato:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80La riga DNAT spiega tutto. Ogni pacchetto ricevuto sulla porta 8080 viene riscritto con destinazione 172.17.0.2:80, cioè l'indirizzo del container sulla rete bridge privata di Docker. Dopo la riscrittura, il pacchetto non è più indirizzato all'host. La decisione di routing lo invia quindi lungo il percorso FORWARD, dove Docker ha già aggiunto regole che accettano il traffico diretto alle proprie reti. La regola deny 8080/tcp resta in attesa in INPUT di un pacchetto che non arriva mai.
Su Ubuntu 24.04, il comando iptables è un front-end per nftables, ma l'ordine delle catene e il risultato sono identici. UFW e Docker scrivono entrambi nella stessa pipeline dei pacchetti del kernel, e il punto di ingresso di Docker viene prima. Questo non riguarda solo UFW: firewalld su un VPS Rocky o AlmaLinux filtra allo stesso punto della pipeline e viene aggirato dalla stessa regola DNAT. Le correzioni riportate di seguito sono quindi applicabili anche in quel caso.
La correzione quotidiana: pubblicare le porte su 127.0.0.1
La maggior parte dei container non avrebbe mai dovuto essere esposta pubblicamente. Un database, un application server dietro un reverse proxy, un pannello di amministrazione o un endpoint per le metriche non dovrebbero rispondere direttamente a Internet. Pubblicali sull'indirizzo di loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOppure in un file Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Funziona perché la regola DNAT di Docker ora corrisponde soltanto ai pacchetti indirizzati a 127.0.0.1. Inoltre, in Docker Engine 28.0.0 e versioni successive, Docker aggiunge una regola nella tabella raw, nella catena PREROUTING, che scarta i pacchetti diretti alla porta di loopback quando arrivano da un'interfaccia diversa dal loopback. In queste versioni, la porta è raggiungibile dall'host e da nient'altro. Nelle versioni precedenti alla 28.0.0, Docker documenta che gli host dello stesso segmento L2, ad esempio macchine collegate allo stesso switch di rete, possono ancora raggiungere le porte pubblicate su localhost. Controlla quindi docker version ed esegui l'aggiornamento prima di fare affidamento su questo comportamento. Verifica il binding:
sudo ss -tlnp | grep 8080Nell'output deve comparire 127.0.0.1:8080, non 0.0.0.0:8080 o [::]:8080. Verifica quindi da un'altra macchina che curl http://your-vps-ip:8080/ non riceva risposta. Un pacchetto inviato al tuo indirizzo pubblico non viene più riscritto da Docker. Raggiunge quindi INPUT, dove la policy predefinita di UFW, che nega il traffico, e la regola deny 8080/tcp descritta in precedenza lo scartano senza rifiutarlo. Di conseguenza, curl resta in attesa e termina con un timeout della connessione, non con "connection refused".
Per i servizi che devono essere accessibili da Internet, esegui un solo reverse proxy che gestisca le porte 80 e 443 e instradi le richieste in base al nome host. Non pubblicare altre porte. Questo è il modello descritto nella guida al reverse proxy Traefik e consente a un'applicazione self-hosted come Nextcloud su un VPS di restare irraggiungibile se non attraverso il proxy. La guida alle basi di Docker Compose spiega come dichiarare le voci ports: e illustra il resto del flusso di lavoro con Compose.
Quando ogni container interno usa il loopback, UFW torna a svolgere il suo compito normale: proteggere le porte servite direttamente dall'host. Crea qui il relativo insieme di regole, quindi esegui i comandi nell'ordine indicato:
Filtraggio effettivo: la chain DOCKER-USER
A volte una porta del container deve restare pubblicata sulla rete, ma con accesso limitato. Un esempio è la porta di replica di un database, che deve essere raggiungibile soltanto dall'indirizzo di un ufficio. A questo scopo Docker fornisce la chain DOCKER-USER. Ogni pacchetto diretto a un container passa attraverso DOCKER-USER prima di raggiungere le regole di accettazione proprie di Docker. La chain è destinata alle regole definite dall'utente e Docker non le elimina né le modifica, nemmeno dopo il riavvio del daemon.
Questa chain appartiene al backend firewall iptables predefinito di Docker. Docker Engine 29 ha aggiunto un backend nftables sperimentale, che si attiva con l'opzione del daemon firewall-backend. In questo backend non esiste la chain DOCKER-USER: il filtraggio personalizzato va inserito in una tabella nftables separata, definita dall'utente, con una base chain sull'hook forward. Se non hai modificato questa opzione, stai usando iptables e tutto ciò che segue si applica al tuo caso. La differenza tra i due strumenti è descritta nella guida al confronto tra iptables e nftables.
Prima del comando è necessario considerare un dettaglio. Quando un pacchetto raggiunge DOCKER-USER, la riscrittura DNAT è già avvenuta. La porta di destinazione del pacchetto è quella del container (80 nel nostro esempio), non quella pubblicata (8080). Una regola che esegue il match su --dport 8080 non corrisponde quindi ad alcun pacchetto. Il metodo affidabile consiste nel verificare la porta a cui il client si è connesso inizialmente, che il connection tracker del kernel conserva:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPLa regola si legge così: per i pacchetti entrati da eth0 e appartenenti a una connessione la cui porta di destinazione originale era 8080, scarta tutto ciò che non proviene da 10.0.0.10. Il match --ctdir ORIGINAL limita la regola alla direzione dal client al container, così i pacchetti di risposta non vengono intercettati per errore. Sostituisci eth0 con la tua interfaccia pubblica; ip route | grep default ne visualizza il nome. Esegui il test come prima: curl dall'indirizzo autorizzato ha esito positivo, mentre da qualsiasi altro indirizzo la connessione va in timeout. Questo blocco è il segnale che una regola DROP sta funzionando, non che dietro la porta non ci sia nulla. La differenza tra una connessione rifiutata e una che va in timeout è il modo più rapido per distinguere una porta filtrata da un servizio che non è semplicemente in ascolto.
Le regole aggiunte con il comando iptables scompaiono al riavvio. Poiché UFW gestisce già questo firewall, il punto corretto in cui renderle persistenti è /etc/ufw/after.rules. Aggiungi un blocco alla fine del file:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITQuindi esegui sudo ufw reload. UFW ricarica quel file a ogni reload e a ogni avvio. Il filtraggio dei container si trova così nello stesso punto del resto del firewall e resta attivo dopo un riavvio e dopo un aggiornamento di Docker.
Perché non disabilitare l'integrazione di Docker con iptables
Le risposte più datate a questo problema suggeriscono di impostare { "iptables": false } in /etc/docker/daemon.json. Non farlo. Le regole del firewall di Docker fanno molto più che pubblicare le porte. La regola di masquerade consente ai container di accedere a Internet tramite l'indirizzo del server host. Se l'integrazione è disabilitata, i container non possono raggiungere i mirror dei pacchetti né chiamare API esterne (application programming interface). Anche le regole DNAT e di isolamento vengono rimosse. La documentazione di Docker avverte inoltre che, in assenza di regole sostitutive, le porte dei container diventano accessibili dagli host della rete locale. Per correggere il bypass dovresti compromettere il networking dei container. Inoltre, dovresti scrivere e gestire manualmente ognuna di queste regole. La documentazione di Docker definisce questa impostazione non adatta alla maggior parte degli utenti e descrive la creazione successiva delle regole come un'operazione estremamente complessa. La catena DOCKER-USER esiste proprio per evitare che sia necessario usare questo switch.
Il lato IPv6 dello stesso problema
Per prima cosa, verificare come appare la porta pubblicata su IPv6:
sudo ss -tlnp | grep 8080A partire da Docker Engine 27, Docker gestisce ip6tables per impostazione predefinita. In una rete Docker con IPv6 abilitato, una porta pubblicata riceve lo stesso trattamento DNAT nelle tabelle IPv6. Il bypass è quindi presente anche in questo caso e si applica la stessa correzione: la catena DOCKER-USER esiste anche in ip6tables, quindi replicare la regola con sudo ip6tables -I DOCKER-USER ... e verificare dall'esterno con curl usando l'indirizzo IPv6 pubblico del server, ad esempio curl -6 http://[2001:db8:2a::1]:8080/. Questo vale per il backend iptables predefinito di Docker. Nel backend nftables sperimentale, invece, la catena DOCKER-USER non esiste in nessuno dei due stack e il filtraggio IPv6 deve essere configurato nella propria tabella nftables ip6.
Su una rete senza IPv6, i client IPv6 vengono gestiti invece da docker-proxy, un normale processo nello spazio utente che resta in ascolto su [::]:8080 e inoltra il traffico al container tramite IPv4. Il traffico diretto a un processo dell'host passa effettivamente attraverso INPUT, quindi UFW può filtrare questo percorso, ma solo quando UFW gestisce IPv6. Il modo per verificare se lo fa e gli altri casi in cui può aprirsi una falla IPv6 su un VPS sono descritti nella guida a UFW e IPv6.
La pubblicazione sull'interfaccia di loopback evita completamente il problema: -p 127.0.0.1:8080:80 si associa esclusivamente al loopback IPv4, quindi non esiste alcun listener IPv6. Inoltre, con Docker Engine 28.0.0 e versioni successive, dall'esterno non è raggiungibile nulla su nessuno dei due stack.
Il modello che resiste
- Pubblica ogni porta interna su
127.0.0.1, in modo che non venga esposta fin dall'inizio, usando Docker Engine 28.0.0 o versioni successive (le versioni precedenti consentono agli host dello stesso segmento L2 di raggiungerla). - Affida l'esposizione pubblica a un unico reverse proxy che gestisce le porte 80 e 443.
- Mantieni UFW con il criterio predefinito di negazione per l'host e consenti SSH e le porte del proxy.
- Filtra le porte dei container realmente pubbliche in
DOCKER-USERsul backend iptables predefinito di Docker, usando come criterio la porta di destinazione originale; rendi persistente la configurazione in/etc/ufw/after.rules. - Lascia attiva l'integrazione iptables di Docker.
Dopo la configurazione iniziale, il comportamento è prevedibile: ufw status descrive l'host e DOCKER-USER descrive i container. Nulla viene pubblicato per errore e il successivo docker run -p che esegui espone esattamente ciò che intendevi pubblicare.
FAQ
Perché riesco a raggiungere il container Docker quando UFW blocca la porta?
Perché Docker pubblica la porta con una regola DNAT nella catena DOCKER della tabella nat, raggiunta da PREROUTING, che riscrive la destinazione del pacchetto verso l'indirizzo del container prima che venga eseguito qualsiasi filtraggio. Il pacchetto segue quindi il percorso FORWARD, mentre le regole di UFW si trovano in INPUT, una catena nella quale il pacchetto non entra. Il firewall non viene mai consultato, quindi le sue regole di rifiuto non hanno effetto sulle porte pubblicate dei container.
Come posso fare in modo che UFW blocchi le porte pubblicate da Docker?
UFW non può farlo direttamente, perché le sue regole si trovano nella catena sbagliata. È possibile smettere di esporre la porta pubblicandola come 127.0.0.1:8080:80, in modo che solo l'host possa raggiungerla con Docker Engine 28.0.0 e versioni successive. In alternativa, è possibile applicare il filtraggio nella catena DOCKER-USER con una regola iptables che, tramite conntrack, corrisponda alla porta di destinazione originale. Salvare la regola in /etc/ufw/after.rules, in modo che venga ripristinata dopo i riavvii e ufw reload.
Devo impostare "iptables": false nel file daemon.json di Docker?
No. Questa impostazione rimuove la maggior parte delle regole firewall e NAT di Docker e causa problemi molto più estesi del solo bypass. I container perdono l'accesso a Internet in uscita perché la regola di masquerade non è più presente. Inoltre, Docker avverte che, senza regole sostitutive, le porte dei container diventano accessibili dagli host della rete locale. Usare invece la pubblicazione su loopback e la catena DOCKER-USER: risolvono l'esposizione senza interrompere il networking dei container.
Docker bypassa UFW anche su IPv6?
Su Docker Engine 27 e versioni successive, la gestione di ip6tables è abilitata per impostazione predefinita. Una porta pubblicata su una rete Docker abilitata per IPv6 viene quindi riscritta aggirando UFW esattamente come su IPv4. Sul backend iptables predefinito di Docker è necessaria la stessa regola DOCKER-USER, replicata con ip6tables. Sul backend nftables sperimentale di Engine 29 non esiste la catena DOCKER-USER, quindi il filtraggio deve essere configurato nella propria tabella nftables. Nelle reti senza IPv6, il processo docker-proxy resta in ascolto su [::] e il relativo traffico passa attraverso INPUT, dove UFW può filtrarlo se gestisce IPv6. La pubblicazione su 127.0.0.1 evita entrambi i casi, perché non c'è alcun processo in ascolto su IPv6.