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 ignora o UFW
O Docker ignora 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 cadeia PREROUTING da tabela nat do kernel. Essa regra reescreve o destino de cada pacote para o endereço privado do contentor antes de o kernel decidir para onde o pacote está a ser encaminhado. O pacote reescrito é então encaminhado para o contentor através da cadeia FORWARD, que o Docker controla. As regras do UFW estão na cadeia INPUT, e o pacote nunca entra nela. Por isso, ufw status mostra a negação predefinida, 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, por isso o UFW nunca é consultado. Este guia demonstra o bypass, explica o mecanismo e apresenta 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 negação predefinida continua a ser a base correta para todo 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 aqui podem reescrever o destino do pacote, e a regra do Docker para uma porta publicada faz exatamente isso.- A decisão de encaminhamento vem a seguir. Um pacote destinado ao próprio host segue para a cadeia
INPUT. Um pacote destinado a qualquer outra máquina segue 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 nas 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 aplicar nesse caso.
A correção habitual: 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 endereçados a 127.0.0.1, e um pacote proveniente da Internet nunca pode transportar legitimamente esse destino. Assim, o kernel descarta-o antes de qualquer regra de firewall ser executada. A porta fica acessível a partir do host e de mais nenhum local. Verifique o binding:
sudo ss -tlnp | grep 8080O resultado deve conter 127.0.0.1:8080, não 0.0.0.0:8080 nem [::]:8080. Em seguida, confirme a partir de outra máquina que curl http://your-vps-ip:8080/ é recusada.
Para os serviços que devem ficar expostos à Internet, execute um único reverse proxy que detenha as portas 80 e 443 e faça o encaminhamento por hostname. Não publique mais nada. Esse é o padrão usado no guia de reverse proxy Traefik e é assim que uma aplicação self-hosted, como o Nextcloud num VPS, permanece inacessível, exceto através do proxy. A forma de declarar entradas ports: e o restante fluxo de trabalho do Compose são abordados no guia de conceitos básicos do Docker Compose.
Com todos os contentores internos em loopback, o UFW volta a executar a sua função normal: proteger as portas que o próprio host disponibiliza. Crie aqui esse conjunto de regras e execute os comandos pela ordem indicada:
Filtragem real: a cadeia DOCKER-USER
Por vezes, uma porta de contentor tem de continuar publicada na rede, mas com acesso restrito. Por exemplo, uma porta de réplica de base de dados que só possa ser acedida a partir do 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, e o Docker nunca escreve regras nessa cadeia. A cadeia existe para as suas regras, e o Docker não altera o seu conteúdo durante os reinícios do daemon.
Há um detalhe importante antes de executar o comando: quando um pacote chega a DOCKER-USER, a reescrita DNAT já ocorreu. 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 estabeleceu originalmente a ligação. O rastreador de ligações do kernel guarda essa informação:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPA regra significa o seguinte: para os pacotes que entraram por eth0 e pertencem a uma ligação cujo destino original era a porta 8080, rejeitar tudo o que não tenha sido enviado a partir 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 apresenta o nome dessa interface. Teste da mesma forma que antes: curl a partir do endereço permitido funciona, enquanto, a partir de qualquer outro local, a ligação expira. Essa espera é o sinal de que uma regra DROP está a funcionar, e não de que não existe nenhum serviço por trás da porta. A diferença entre uma ligação recusada e uma ligação que expira é 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 o reboot. Como o UFW já gere esta firewall, o local adequado para as manter é /etc/ufw/after.rules. Adicione 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 mantém-se após um reboot ou uma atualização do Docker.
Por que não deve desativar a integração do Docker com o iptables
Respostas 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 é o que dá aos contentores acesso de saída à Internet através do endereço do host. Com a integração desativada, os contentores não conseguem obter imagens, aceder a mirrors de pacotes ou chamar qualquer API externa (application programming interface). As regras DNAT são o que fazem -p funcionar, por isso as portas publicadas deixam de funcionar completamente. As regras de isolamento que mantêm separadas as redes do Compose também desaparecem. Para corrigir o bypass, estaria a quebrar a rede dos contentores, e teria de escrever e manter manualmente cada uma dessas regras. A documentação do próprio Docker descreve esta definição como destinada a quem pretende fazer exatamente isso. A cadeia DOCKER-USER existe precisamente para que ninguém precise deste switch.
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 nesse caso e a mesma correção se aplica: a cadeia DOCKER-USER também existe no ip6tables. 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/.
Numa rede sem IPv6, os clientes IPv6 são tratados por docker-proxy, um processo normal no espaço do utilizador que escuta em [::]:8080 e encaminha o tráfego para o contentor através de IPv4. O tráfego destinado a um processo do host passa por INPUT. Por isso, 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 a questão: -p 127.0.0.1:8080:80 associa-se apenas à loopback IPv4. Assim, não existe nenhum listener IPv6 nem nada acessível a partir do exterior em nenhuma das duas pilhas.
O padrão que funciona
- Publique todas as portas internas em
127.0.0.1, para que nunca sejam expostas. - Entregue a exposição pública a um único reverse proxy, que controla as portas 80 e 443.
- Mantenha a política padrão do UFW como deny no host, permitindo SSH e as portas do proxy.
- Filtre as portas dos contentores que são realmente públicas em
DOCKER-USER, com correspondência pela porta de destino original, e persista as regras em/etc/ufw/after.rules. - Mantenha ativada a integração do Docker com iptables.
Depois de configurado, isto elimina surpresas: ufw status descreve o host e DOCKER-USER descreve os contentores. 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 PREROUTING, que reescreve o destino do pacote para o endereço do contentor antes de qualquer filtragem. Em seguida, o pacote percorre o caminho FORWARD, e as regras do UFW estão em INPUT, uma cadeia pela qual o pacote nunca passa. O firewall nunca é consultado, por isso as regras de bloqueio não têm efeito nas portas publicadas dos contentores.
Como faço o UFW bloquear as portas publicadas pelo Docker?
O próprio UFW não consegue, porque as respetivas 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 anfitrião lhe possa aceder, ou filtrar na cadeia DOCKER-USER com uma regra iptables que corresponda à porta de destino original através do conntrack. Guarde essa regra em /etc/ufw/after.rules para que sobreviva a reinícios e ufw reload.
Devo definir "iptables": false no daemon.json do Docker?
Não. Essa definição remove todas as regras de firewall e NAT do Docker, o que causa problemas muito além deste bypass. Os contentores perdem o acesso de saída à Internet porque a regra de masquerade desaparece, e as portas publicadas deixam de funcionar porque as regras DNAT são removidas. Use a publicação na interface de loopback e a cadeia DOCKER-USER; estas opções corrigem a exposição sem quebrar a rede dos contentores.
O Docker também contorna o UFW em IPv6?
No Docker Engine 27 e posteriores, a gestão de ip6tables está ativada por predefinição. Por isso, uma porta publicada numa rede Docker com IPv6 ativado é reescrita contornando o UFW, exatamente como em IPv4, e precisa da mesma regra DOCKER-USER replicada com ip6tables. Em redes sem IPv6, o processo docker-proxy fica à escuta em [::], e esse tráfego passa por INPUT, onde o UFW pode filtrá-lo se gerir IPv6. A publicação em 127.0.0.1 evita ambos os casos, porque nada fica à escuta em IPv6.