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 pubblicate dei container non passano mai attraverso le regole firewall gestite da UFW. Quando esegui docker run -p 8080:80, Docker scrive una regola DNAT (destination network address translation) nella catena PREROUTING della tabella nat del kernel. La regola riscrive la destinazione di ogni pacchetto nell'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, che Docker controlla. Le regole di UFW si trovano nella catena INPUT, ma il pacchetto non vi entra mai. Per questo ufw status mostra il criterio predefinito di rifiuto, sudo ufw deny 8080 indica un'operazione riuscita e la porta 8080 continua a rispondere a 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 vengono semplicemente applicate in una fase precedente del percorso del pacchetto, quindi UFW non viene mai interpellato. Questa guida dimostra l'aggiramento, spiega il meccanismo e illustra quindi le due soluzioni efficaci: pubblicare le porte su 127.0.0.1 e filtrare nella catena DOCKER-USER. Se UFW è nuovo per te, configurarlo prima seguendo la guida di base al firewall UFW, perché un firewall con criterio predefinito di rifiuto resta comunque 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 in un ordine fisso, e l'intero problema dipende da questo ordine.
PREROUTINGviene eseguita per prima. Le regole presenti qui possono riscrivere la destinazione del pacchetto, e la regola di Docker per una porta pubblicata fa esattamente questo.- Segue la decisione di routing. Un pacchetto destinato all'host stesso passa alla chain
INPUT. Un pacchetto destinato a qualsiasi altra macchina passa alla chainFORWARD. - 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. Qualsiasi pacchetto in arrivo sulla porta 8080 viene riscritto con destinazione 172.17.0.2:80, l'indirizzo del container sulla rete bridge privata di Docker. Dopo la riscrittura, il pacchetto non è più destinato 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 funge da interfaccia per nftables, ma l'ordine delle chain 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 comportamento non è specifico di UFW: firewalld su un VPS Rocky o AlmaLinux filtra nello stesso punto della pipeline e viene aggirato dalla stessa regola DNAT. Le correzioni riportate di seguito si applicano quindi anche in quel caso.
La correzione ordinaria: pubblicare le porte su 127.0.0.1
La maggior parte dei container non avrebbe mai dovuto essere esposta direttamente a Internet. Un database, un application server dietro un reverse proxy, un pannello di amministrazione o un endpoint per le metriche non devono rispondere direttamente alle richieste provenienti da 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. Un pacchetto proveniente da Internet non può avere legittimamente quella destinazione, quindi il kernel lo scarta prima che venga applicata qualsiasi regola del firewall. La porta è raggiungibile dall'host e da nessun altro sistema. 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 altro computer che curl http://your-vps-ip:8080/ venga rifiuta.
Per i servizi che devono essere accessibili da Internet, esegui un unico reverse proxy che gestisca le porte 80 e 443 e instradi le richieste in base al nome host, senza pubblicare altre porte. È 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 dichiarazione delle voci ports: e il resto del flusso di lavoro Compose sono descritti nella guida alle nozioni di base di Docker Compose.
Quando tutti i container interni usano 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 catena 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 catena DOCKER-USER. Ogni pacchetto diretto a un container passa attraverso DOCKER-USER prima delle regole di accettazione gestite da Docker. Docker non scrive mai regole in questa catena. La catena è riservata alle regole dell'amministratore e Docker non ne modifica il contenuto quando il daemon viene riavviato.
Prima di eseguire il comando, è importante considerare un aspetto: quando un pacchetto raggiunge DOCKER-USER, la riscrittura DNAT è già avvenuta. La porta di destinazione del pacchetto è quella del container, 80 nell'esempio, non quella pubblicata, 8080. Una regola che usa --dport 8080 come criterio non corrisponde quindi ad alcun pacchetto. Il metodo affidabile consiste nel filtrare la porta a cui il client si è connesso originariamente, 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 significa: per i pacchetti entrati tramite eth0 e appartenenti a una connessione la cui porta di destinazione originale era 8080, elimina tutti i pacchetti che non provengono da 10.0.0.10. Il criterio --ctdir ORIGINAL limita la regola alla direzione dal client al container, evitando di intercettare per errore i pacchetti di risposta. Sostituisci eth0 con la tua interfaccia pubblica; ip route | grep default la identifica. Esegui il test come prima: curl dall'indirizzo autorizzato deve riuscire, mentre da qualsiasi altro indirizzo la connessione deve andare in timeout. Questo blocco è il comportamento tipico di una regola DROP applicata correttamente, non di una porta senza alcun servizio in ascolto. La differenza tra una connessione rifiutata e una connessione che va in timeout è il modo più rapido per distinguere una porta filtrata da un servizio che semplicemente non è 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 meno recenti a questo problema suggeriscono di impostare { "iptables": false } in /etc/docker/daemon.json. Non farlo. Le regole del firewall di Docker svolgono funzioni molto più ampie della pubblicazione delle porte. La regola di masquerade consente ai container di accedere a Internet tramite l'indirizzo dell'host. Se l'integrazione è disabilitata, i container non possono scaricare immagini, raggiungere i mirror dei pacchetti o chiamare API esterne. Le regole DNAT rendono possibile -p. Di conseguenza, le porte pubblicate smettono completamente di funzionare. Vengono rimosse anche le regole di isolamento che tengono separate le reti Compose. Risolveresti il bypass interrompendo il networking dei container, e dovresti scrivere e gestire manualmente ognuna di queste regole. La documentazione di Docker descrive questa impostazione come destinata a chi intende fare esattamente questo. La catena DOCKER-USER esiste proprio per evitare che sia necessario usare questa opzione.
Il lato IPv6 dello stesso problema
Per prima cosa verifica come appare la porta pubblicata su IPv6:
sudo ss -tlnp | grep 8080A partire da Docker Engine 27, Docker gestisce ip6tables per impostazione predefinita. Su una rete Docker con IPv6 abilitato, una porta pubblicata riceve lo stesso trattamento DNAT nelle tabelle IPv6. Di conseguenza, anche in questo caso esiste lo stesso bypass e si applica la stessa correzione: la catena DOCKER-USER esiste anche in ip6tables, quindi replica la regola con sudo ip6tables -I DOCKER-USER ... e verifica dall'esterno usando curl sull'indirizzo IPv6 pubblico del server, ad esempio curl -6 http://[2001:db8:2a::1]:8080/.
Su una rete senza IPv6, invece, i client IPv6 vengono gestiti da docker-proxy, un normale processo nello spazio utente in ascolto su [::]:8080, che inoltra il traffico al container tramite IPv4. Il traffico diretto a un processo dell'host passa effettivamente da INPUT, quindi UFW può filtrare questo percorso, ma solo se UFW gestisce IPv6. Il modo per verificarlo e gli altri casi in cui si crea una lacuna nella gestione di 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 soltanto al loopback IPv4, quindi non esiste alcun listener IPv6 e dall'esterno non è possibile raggiungere il servizio tramite nessuno dei due protocolli.
Il modello affidabile
- Pubblica ogni porta interna su
127.0.0.1, in modo che non venga mai esposta. - Affida l'accesso pubblico a un unico reverse proxy che gestisce le porte 80 e 443.
- Mantieni UFW con il criterio predefinito di negazione per l'host, consentendo SSH e le porte del proxy.
- Filtra le porte dei container realmente pubbliche in
DOCKER-USER, sulla base della porta di destinazione originale, e rendi persistenti le regole in/etc/ufw/after.rules. - Lascia attiva l'integrazione iptables di Docker.
Dopo la configurazione iniziale, questa impostazione elimina le sorprese: ufw status descrive l'host e DOCKER-USER descrive i container. Nulla viene pubblicato per errore e il prossimo docker run -p che esegui espone esattamente ciò che hai previsto.
FAQ
Perché posso raggiungere il mio container Docker quando UFW blocca la porta?
Perché Docker pubblica la porta con una regola DNAT nella catena PREROUTING, che riscrive la destinazione del pacchetto nell'indirizzo del container prima che venga applicato qualsiasi filtro. Il pacchetto segue quindi il percorso FORWARD, mentre le regole di UFW si trovano in INPUT, una catena in cui il pacchetto non entra mai. Il firewall non viene mai consultato, quindi le relative regole di negazione 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 errata. È possibile smettere di esporre la porta pubblicandola come 127.0.0.1:8080:80, così che solo l'host possa raggiungerla, oppure filtrare nella catena DOCKER-USER con una regola iptables che corrisponda alla porta di destinazione originale tramite conntrack. Rendere persistente la regola in /etc/ufw/after.rules, così che sopravviva ai riavvii, e in ufw reload.
Devo impostare "iptables": false nel file daemon.json di Docker?
No. Questa impostazione rimuove tutte le regole firewall e NAT di Docker, causando problemi molto più estesi del solo bypass. I container perdono l'accesso a Internet in uscita perché viene rimossa la regola di masquerade, mentre le porte pubblicate smettono di funzionare perché vengono rimosse le regole DNAT. 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?
A partire da Docker Engine 27, la gestione di ip6tables è attiva per impostazione predefinita. Di conseguenza, una porta pubblicata su una rete Docker abilitata per IPv6 viene riscritta aggirando UFW esattamente come su IPv4 e richiede la stessa regola DOCKER-USER, replicata con ip6tables. Sulle 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 resta nulla in ascolto su IPv6.