Docker ignora o UFW: como bloquear portas publicadas
Veja por que uma porta negada pelo UFW ainda responde: o Docker usa DNAT e a cadeia DOCKER-USER. Aprenda as duas correções que funcionam.
Por que o Docker contorna o UFW
O Docker contorna o UFW porque as portas publicadas dos contentores nunca passam pelas regras de firewall geridas pelo UFW. Quando executa docker run -p 8080:80, o Docker escreve uma regra DNAT (tradução de endereços de rede de destino) na sua própria cadeia DOCKER, na tabela nat do kernel. A cadeia PREROUTING encaminha os pacotes para essa cadeia. A regra altera o destino de cada pacote para o endereço privado do contentor antes de o kernel decidir para onde o pacote deve ser encaminhado. O pacote alterado é depois encaminhado para o contentor através da cadeia FORWARD, que é controlada pelo Docker. As regras do UFW estão na cadeia INPUT, mas o pacote nunca entra nela. Por isso, ufw status mostra a rejeição por predefinição, sudo ufw deny 8080 indica sucesso e a porta 8080 continua a responder a toda a Internet.
Isto não é um erro do Docker, e o UFW não está avariado. Ambas as ferramentas configuram o mesmo firewall do kernel. As regras do Docker atuam simplesmente numa fase anterior do percurso do pacote, pelo que o UFW nunca é consultado. Este guia demonstra o contorno, explica o mecanismo e aborda as duas correções que funcionam: publicar portas em 127.0.0.1 e filtrar na cadeia DOCKER-USER. Se ainda não conhece o UFW, configure-o primeiro com o guia básico do firewall UFW, porque um firewall com rejeição por predefinição continua a ser a base correta para o restante servidor.
Veja o bypass no seu próprio servidor
Comece com um VPS onde o UFW esteja ativo, com uma política padrão de negação para o tráfego de entrada. Execute um contentor web com uma porta publicada:
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 nenhuma regra para a porta 8080. Segundo o próprio relatório da firewall, a porta está fechada. Agora teste a partir de outra máquina, não a partir do próprio servidor:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKO contentor responde. Adicione uma regra explícita de negação e teste novamente:
sudo ufw deny 8080/tcpA porta continua a responder, porque a regra de negação está numa chain que o pacote nunca percorre. O UFW não falhou. Nunca foi consultado. É também por isso que o problema passa tão facilmente despercebido: não é apresentada qualquer mensagem de erro, o deploy funciona e o estado da firewall parece exatamente o de um servidor corretamente protegido.
O mecanismo: PREROUTING é executado antes de INPUT
O kernel processa um pacote recebido numa ordem fixa, e todo o problema está nessa ordem.
PREROUTINGé executado primeiro. As regras alcançadas a partir daqui podem reescrever o destino do pacote. A regra do Docker para uma porta publicada, na cadeiaDOCKERpara a qualPREROUTINGencaminha, faz exatamente isso.- A decisão de encaminhamento vem a seguir. Um pacote destinado ao próprio host vai para a cadeia
INPUT. Um pacote destinado a qualquer outra máquina vai para a cadeiaFORWARD. - As regras do UFW estão em
INPUT. As regras do Docker estão emFORWARD.
Veja a regra do Docker para o contentor que acabou de iniciar:
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:80A linha DNAT explica tudo. Qualquer pacote recebido na porta 8080 tem o destino reescrito para 172.17.0.2:80, o endereço do contentor na rede bridge privada do Docker. Depois da reescrita, o pacote já não está destinado ao host. Por isso, a decisão de encaminhamento envia-o pelo caminho FORWARD, onde o Docker já adicionou regras que aceitam tráfego para as suas próprias redes. A regra deny 8080/tcp fica à espera em INPUT de um pacote que nunca chega.
No Ubuntu 24.04, o comando iptables funciona como uma interface sobre nftables, mas a ordem das cadeias e o resultado são idênticos. O UFW e o Docker escrevem ambos no mesmo pipeline de pacotes do kernel, e o ponto de entrada do Docker é anterior. Isto não é específico do UFW: o firewalld num VPS Rocky ou AlmaLinux filtra no mesmo ponto desse pipeline e é contornado pela mesma regra DNAT. Por isso, as correções abaixo também são as que deve utilizar nesse caso.
A correção do dia a dia: publique as portas em 127.0.0.1
A maioria dos contentores nunca precisou de ser pública. Uma base de dados, um servidor de aplicações atrás de um reverse proxy, um painel de administração ou um endpoint de métricas não devem responder diretamente à Internet. Publique-os no endereço de loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOu num ficheiro Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Isto funciona porque a regra DNAT do Docker passa a corresponder apenas a pacotes destinados a 127.0.0.1. No Docker Engine 28.0.0 e posteriores, o Docker também adiciona uma regra na tabela raw, na cadeia PREROUTING, que descarta qualquer pacote destinado a essa porta de loopback quando chega por uma interface diferente da interface de loopback. Nessas versões, a porta fica acessível a partir do host e de mais nenhum local. Em versões anteriores a 28.0.0, o Docker documenta que hosts no mesmo segmento L2, por exemplo máquinas ligadas ao mesmo switch de rede, ainda podem alcançar portas publicadas em localhost. Por isso, verifique docker version e atualize antes de depender deste comportamento. Confirme a associação:
sudo ss -tlnp | grep 8080O resultado deve conter 127.0.0.1:8080, não 0.0.0.0:8080 nem [::]:8080. Depois, confirme a partir de outra máquina que curl http://your-vps-ip:8080/ não obtém resposta. Um pacote enviado para o seu endereço público já não é reescrito pelo Docker. Por isso, chega a INPUT, onde a política deny predefinida do UFW e a regra deny 8080/tcp definida anteriormente o descartam silenciosamente, em vez de o rejeitarem. O curl fica, portanto, à espera e termina com um timeout de ligação, não com "connection refused".
Para os serviços que devem ficar acessíveis pela Internet, execute um único reverse proxy que controle as portas 80 e 443 e faça o encaminhamento pelo nome do host. Não publique mais nada. Esse é o padrão usado no guia de reverse proxy do Traefik e é assim que uma aplicação self-hosted, como o Nextcloud num VPS, permanece inacessível, exceto através do seu proxy. A forma de declarar entradas ports: e o restante do fluxo de trabalho do Compose são explicados no guia básico do Docker Compose.
Com todos os contentores internos em loopback, o UFW volta a executar a sua função normal: proteger as portas servidas pelo próprio host. Crie aqui esse conjunto de regras e execute os comandos pela ordem indicada:
Filtragem real: a cadeia DOCKER-USER
Por vezes, uma porta de um contentor tem de continuar publicada na rede, mas com acesso restrito. Por exemplo, uma porta de réplica de base de dados que só pode ser alcançada por um endereço de um escritório. Para isso, o Docker disponibiliza a cadeia DOCKER-USER. Todos os pacotes destinados a qualquer contentor passam por DOCKER-USER antes das próprias regras de aceitação do Docker. A cadeia existe para as suas regras, e o Docker não as elimina nem modifica, incluindo durante reinícios do daemon.
Esta cadeia pertence ao backend de firewall iptables predefinido do Docker. O Docker Engine 29 adicionou um backend nftables experimental, que tem de ser ativado com a opção de daemon firewall-backend. Nesse backend não existe a cadeia DOCKER-USER: a filtragem personalizada é colocada numa tabela nftables separada, criada por si, com uma cadeia base no hook forward. Se não alterou essa opção, está a usar iptables e tudo o que se segue é aplicável. A diferença entre as duas ferramentas é explicada no guia sobre iptables e nftables.
Há um detalhe importante antes do comando: quando um pacote chega a DOCKER-USER, a reescrita DNAT já aconteceu. A porta de destino do pacote é a porta do contentor (80 no nosso exemplo), não a porta publicada (8080). Por isso, uma regra que corresponda a --dport 8080 não corresponde a nenhum pacote. A forma fiável é corresponder à porta para a qual o cliente fez originalmente a ligação, que o rastreador de ligações do kernel conserva:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPLeia a regra assim: para pacotes que entraram por eth0 e pertencem a uma ligação cuja porta de destino original era 8080, eliminar tudo o que não foi enviado de 10.0.0.10. A correspondência --ctdir ORIGINAL limita a regra à direção cliente-contentor, para que os pacotes de resposta não sejam bloqueados por engano. Substitua eth0 pela sua interface pública; ip route | grep default identifica essa interface. Teste da mesma forma que antes: curl a partir do endereço permitido é bem-sucedido, e a partir de qualquer outro local a ligação excede o tempo limite. Essa espera é o sinal de que uma regra DROP está a funcionar, e não de que não existe nada por trás da porta. A diferença entre uma ligação recusada e uma ligação que excede o tempo limite é a forma mais rápida de distinguir uma porta filtrada de um serviço que simplesmente não está a escutar.
As regras adicionadas com o comando iptables desaparecem durante um reboot. Como o UFW já gere esta firewall, o local adequado para as manter é /etc/ufw/after.rules. Acrescente um bloco no fim do ficheiro:
*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
COMMITEm seguida, execute sudo ufw reload. O UFW reaplica esse ficheiro em cada reload e em cada boot. Assim, a filtragem dos contentores fica no mesmo local que o resto da firewall e sobrevive tanto a um reboot como a uma atualização do Docker.
Por que não deve desativar a integração do Docker com o iptables
Respostas mais antigas para este problema sugerem definir { "iptables": false } em /etc/docker/daemon.json. Não faça isso. As regras de firewall do Docker fazem muito mais do que publicar portas. A regra de masquerade é responsável por permitir que os contentores acedam à Internet através do endereço do host. Por isso, com a integração desativada, os contentores não conseguem aceder a mirrors de pacotes nem chamar APIs externas. As regras de DNAT e de isolamento também deixam de existir. A documentação do Docker avisa que, sem regras substitutas, as portas dos contentores ficam acessíveis a hosts na rede local. Tentaria corrigir o bypass quebrando a rede dos contentores. Além disso, teria de escrever e manter manualmente todas essas regras. A própria documentação do Docker considera esta configuração inadequada para a maioria dos utilizadores e descreve a criação posterior das regras como extremamente complexa. A chain DOCKER-USER existe precisamente para que ninguém precise desta opção.
O lado IPv6 do mesmo problema
Primeiro, verifique como a porta publicada aparece em IPv6:
sudo ss -tlnp | grep 8080Desde o Docker Engine 27, o Docker gere o ip6tables por predefinição. Numa rede Docker com IPv6 ativado, uma porta publicada recebe o mesmo tratamento DNAT nas tabelas IPv6. Por isso, o mesmo bypass existe e aplica-se a mesma correção: a cadeia DOCKER-USER também existe no ip6tables, portanto replique a regra com sudo ip6tables -I DOCKER-USER ... e teste a partir do exterior com curl, usando o endereço IPv6 público do servidor, por exemplo curl -6 http://[2001:db8:2a::1]:8080/. Isto aplica-se ao backend iptables predefinido do Docker. No backend nftables experimental, não existe uma cadeia DOCKER-USER em nenhuma das duas pilhas. Nesse caso, a filtragem IPv6 deve ser configurada na sua própria tabela ip6 do nftables.
Numa rede sem IPv6, os clientes IPv6 são tratados por docker-proxy, um processo normal em user space que escuta em [::]:8080 e encaminha o tráfego para o contentor através de IPv4. O tráfego destinado a um processo no host passa por INPUT, portanto o UFW pode filtrar esse caminho, mas apenas quando o UFW gere IPv6. A forma de confirmar isso e as outras formas de surgir uma falha de filtragem IPv6 numa VPS são explicadas no guia do UFW e IPv6.
Publicar na interface loopback evita toda esta questão: -p 127.0.0.1:8080:80 liga-se apenas à loopback IPv4, portanto não existe nenhum listener IPv6. A partir do Docker Engine 28.0.0, inclusive, também não há nada acessível a partir do exterior em nenhuma das duas pilhas.
O padrão que se mantém
- Publique todas as portas internas em
127.0.0.1, para que nunca sejam expostas desde o início, no Docker Engine 28.0.0 ou posterior (versões anteriores permitem que hosts no mesmo segmento as alcancem). - Atribua a exposição pública a um único reverse proxy que seja responsável pelas portas 80 e 443.
- Mantenha a política padrão do UFW como deny para o host, permitindo SSH e as portas do proxy.
- Filtre as portas de containers realmente públicas em
DOCKER-USER, no backend iptables padrão do Docker, com correspondência pela porta de destino original, e persista as regras em/etc/ufw/after.rules. - Mantenha a integração iptables do Docker ativada.
Configurado uma vez, isto elimina a surpresa: ufw status descreve o host e DOCKER-USER descreve os containers. Nada é publicado por acidente, e o próximo docker run -p que executar expõe exatamente o que pretendia.
FAQ
Por que consigo aceder ao meu contentor Docker quando o UFW bloqueia a porta?
Porque o Docker publica a porta com uma regra DNAT na cadeia DOCKER da tabela nat, alcançada a partir de PREROUTING, que reescreve o destino do pacote para o endereço do contentor antes de qualquer filtragem. O pacote segue depois pelo caminho FORWARD, e as regras do UFW estão em INPUT, uma cadeia na qual o pacote nunca entra. O firewall nunca é consultado, pelo que as regras de negação não têm efeito nas portas publicadas pelos contentores.
Como faço o UFW bloquear as portas publicadas pelo Docker?
O próprio UFW não consegue, porque as suas regras estão na cadeia errada. Pode deixar de expor a porta, publicando-a como 127.0.0.1:8080:80, para que apenas o host lhe possa aceder no Docker Engine 28.0.0 e posteriores. Em alternativa, filtre na cadeia DOCKER-USER com uma regra iptables que corresponda à porta de destino original através do conntrack. Persista essa regra em /etc/ufw/after.rules para que sobreviva a reinícios e a ufw reload.
Devo definir "iptables": false em daemon.json do Docker?
Não. Essa definição remove a maioria das regras de firewall e NAT do Docker, o que causa muito mais problemas do que o bypass. Os contentores perdem o acesso à Internet de saída porque a regra de masquerade desaparece, e o Docker avisa que, sem regras de substituição, as portas dos contentores ficam acessíveis aos hosts da rede local. Use a publicação em loopback e a cadeia DOCKER-USER. Estas opções corrigem a exposição sem interromper a rede dos contentores.
O Docker também ignora o UFW em IPv6?
No Docker Engine 27 e posteriores, a gestão de ip6tables está ativada por predefinição. Assim, uma porta publicada numa rede Docker com IPv6 ativado é reescrita à margem do UFW, exatamente como em IPv4. No backend iptables predefinido do Docker, também precisa da mesma regra DOCKER-USER, replicada com ip6tables. No backend nftables experimental do Engine 29, não existe a cadeia DOCKER-USER. Nesse caso, a filtragem deve ser feita na sua própria tabela nftables. Em redes sem IPv6, o processo docker-proxy escuta em [::], e esse tráfego passa por INPUT, onde o UFW pode filtrá-lo se gerir IPv6. Publicar em 127.0.0.1 evita ambos os casos, porque não existe qualquer processo a escutar em IPv6.