DEV Community

Jonas Oliveira
Jonas Oliveira

Posted on Originally published at tecmestre.com.br

Fail2ban no Nginx Proxy Manager com Docker e DOCKER-USER

Essa configuração nasceu de um caso real: o Nginx Proxy Manager estava em Docker, scanners apareciam o tempo todo nos access logs e o Fail2ban precisava cortar esse tráfego antes de ele chegar aos containers.

O detalhe é que, quando uma porta é publicada pelo Docker, o caminho do pacote não é o mesmo de um serviço escutando diretamente no host. O Docker cria regras de NAT e encaminhamento no firewall do próprio Linux. Por isso, um ufw deny pode existir e ainda assim não ser o ponto correto para bloquear uma conexão destinada a uma porta publicada pelo container.

No ambiente que usei, a solução foi executar o Fail2ban no host, acompanhar os logs persistidos do Nginx Proxy Manager e aplicar o bloqueio na chain DOCKER-USER.

Escopo deste procedimento: ele foi validado com Nginx Proxy Manager em rede bridge, Docker usando backend iptables (inclusive quando o comando iptables é fornecido por iptables-nft) e com a chain DOCKER-USER ativa. Redes host, macvlan, ipvlan e o backend nftables nativo do Docker seguem caminhos diferentes. Se o domínio estiver atrás da Cloudflare, leia a seção específica antes de ativar o ban automático.

Por que o UFW pode não bloquear uma porta publicada pelo Docker?

Não é que o container tenha um firewall próprio. O Docker cria e gerencia regras no firewall do host para fazer NAT, isolamento e encaminhamento das redes bridge.

Quando você publica, por exemplo:

ports:
  - "80:80"
  - "443:443"
Enter fullscreen mode Exit fullscreen mode

o tráfego dessas portas é desviado pelas regras de NAT do Docker antes de passar pelo fluxo normal que o UFW usa em INPUT e OUTPUT. A própria documentação do Docker trata UFW e publicação de portas como uma incompatibilidade que precisa ser considerada.

Foi por isso que eu não deixei o bloqueio apenas no UFW. Para esse cenário, o ponto de controle passou a ser a DOCKER-USER, que é processada antes das regras de encaminhamento criadas pelo Docker.

Referência: Docker Docs — Packet filtering and firewalls.

Como o bloqueio funciona

Existem dois fluxos diferentes: detecção e bloqueio das próximas conexões.

1) Detecção
Scanner → Nginx Proxy Manager → access.log → Fail2ban
                                      ↓
                         instala regra em DOCKER-USER

2) Próxima tentativa do IP banido
Internet → NAT do Docker → DOCKER-USER → DROP
                                  ↓
                           não chega ao NPM
Enter fullscreen mode Exit fullscreen mode

Ou seja: a requisição que gerou o evento já chegou ao NPM e foi registrada. O Fail2ban usa esse log para preparar o bloqueio das tentativas seguintes. O serviço continua no container; quem acompanha os logs e manipula o firewall é o Fail2ban instalado no host.

Recriação visual da tela Proxy Hosts do Nginx Proxy Manager com dados ilustrativosRecriação visual baseada na interface pública do Nginx Proxy Manager. Os domínios, IPs e destinos exibidos são ilustrativos; o objetivo é mostrar onde ficam origem, destino, SSL, acesso e status dos Proxy Hosts.

Antes de começar: confirme que DOCKER-USER existe

Execute no host Docker:

sudo iptables -L DOCKER-USER -n
sudo iptables -S DOCKER-USER
Enter fullscreen mode Exit fullscreen mode

Se a chain existir, este procedimento é compatível com esse ponto da arquitetura.

Se o host também publica os serviços por IPv6, valide o caminho IPv6 separadamente:

sudo ip6tables -S DOCKER-USER
sudo ip6tables -L DOCKER-USER -n
Enter fullscreen mode Exit fullscreen mode

Não assuma que IPv4 e IPv6 seguem exatamente o mesmo caminho. Em ambientes dual-stack, valide os dois antes de considerar o bloqueio concluído.

Se receber algo como No chain/target/match by that name, não crie uma chain manualmente só para continuar o tutorial. Primeiro confirme como o Docker está configurando o firewall.

Desde o Docker Engine 29 existe suporte ao backend nftables nativo. Nesse modo não existe uma DOCKER-USER equivalente; o Docker orienta usar tabelas e base chains nftables separadas, com hook e prioridade adequados. Esse suporte ainda é tratado como experimental na documentação atual.

Referência: Docker Docs — Docker with nftables.

Passo 1 — instalar e conferir o Fail2ban

fail2ban-client --version
Enter fullscreen mode Exit fullscreen mode

Se ainda não estiver instalado:

sudo apt update
sudo apt install -y fail2ban
Enter fullscreen mode Exit fullscreen mode

Antes de criar o filtro, confirme caminho e formato dos logs:

ls -lh /srv/nginxproxymanager/data/logs/proxy-host-*_access.log
head -n 3 /srv/nginxproxymanager/data/logs/proxy-host-*_access.log
Enter fullscreen mode Exit fullscreen mode

No ambiente usado neste artigo, os arquivos estavam em /srv/nginxproxymanager/data/logs/ e continham o campo [Client IP]. As regex abaixo dependem desse formato. Se o seu NPM gerar um layout diferente, ajuste o filtro antes de ativar o jail.

Passo 2 — criar o filtro dos scanners

Crie:

/etc/fail2ban/filter.d/nginx-npm.conf
Enter fullscreen mode Exit fullscreen mode
sudo tee /etc/fail2ban/filter.d/nginx-npm.conf > /dev/null << 'EOF'
[Definition]

failregex =
    ^\[.*?\] \S+ \S+ 404 - \S+ \S+ \S+ "/cgi-bin/[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ 404 - \S+ \S+ \S+ "/\S+\.(action|do|jsp|jsf)(\?[^"]*)?" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ \d+ - \S+ \S+ \S+ "[^"]*(\.\./|%%2e%%2e|%%252e%%252e|/etc/passwd|/proc/self)[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ \d+ - \S+ \S+ \S+ "[^"]*(php://input|php://filter|allow_url_include|auto_prepend_file|data://|expect://|cmd=|shell_exec=|passthru=)[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ (404|499) - GET \S+ \S+ "/wp-content/plugins/\S+/readme\.txt" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ 404 - \S+ \S+ \S+ "/[^"]*javax\.faces\.resource[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ 404 - \S+ \S+ \S+ "/reports/rwservlet[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ 444 - \S+ \S+ \S+ "[^"]*" \[Client <HOST>\]
    ^\[.*?\] \S+ \S+ \d+ .* \[Client <HOST>\] .* "\(\)\s*\{
    (?i)^\[.*?\] \S+ \S+ \d+ - \S+ \S+ \S+ "[^"]*(?:union(?:%%[0-9a-f]{2}|[\s+])+(?:all(?:%%[0-9a-f]{2}|[\s+])+)?select|xp_cmdshell|waitfor(?:%%[0-9a-f]{2}|[\s+])+delay|information_schema|load_file\s*[\(%%]|into(?:%%[0-9a-f]{2}|\s)+outfile|sleep\s*[\(%%]|pg_sleep\s*[\(%%]|benchmark\s*[\(%%]|drop(?:%%[0-9a-f]{2}|\s)+(?:table|database)|0x[0-9a-f]{10,})[^"]*" \[Client <HOST>\]

ignoreregex =
EOF
Enter fullscreen mode Exit fullscreen mode

Este é o filtro utilizado no ambiente que originou este artigo. As expressões foram criadas a partir de probes observados nos logs reais do Nginx Proxy Manager: cgi-bin, traversal, wrappers PHP, plugins WordPress, JSF, Oracle Reports, Shellshock e padrões recorrentes de SQL injection. Para a versão publicada, mantive as regras de detecção e removi apenas as exceções baseadas em User-Agent.

Eu faria duas ressalvas antes de copiar:

  • não use User-Agent para liberar Googlebot, Bingbot ou qualquer outro bot; esse campo é trivial de falsificar e vira um bypass;
  • a expressão que trata HTTP 444 só deve ficar se esse status, no seu Nginx, for usado de propósito para tráfego que você quer descartar. Se houver 444 legítimo, remova essa linha.

Passo 3 — usar a action nativa do Fail2ban na DOCKER-USER

Na primeira implementação eu usei uma action própria que executava diretamente iptables -I DOCKER-USER -s <ip> -j DROP. Funcionou, mas para um procedimento que vai ficar público eu prefiro a action nativa do Fail2ban.

O motivo é simples: ela cria uma chain f2b-..., gerencia start, stop, check, ban e unban e evita manter um arquivo de action artesanal sem necessidade.

Como este jail protege tráfego web do Nginx Proxy Manager, preferi limitar o bloqueio às portas HTTP e HTTPS em vez de cortar todas as portas encaminhadas do Docker:

action = iptables[type=multiport, name=nginx-npm-scanner, port="80,443", protocol=tcp, chain=DOCKER-USER, blocktype=DROP]
Enter fullscreen mode Exit fullscreen mode

Essa sintaxe usa a action iptables atual do próprio Fail2ban com type=multiport. O projeto ainda distribui o nome legado iptables-multiport, mas ele está marcado como substituído pela forma iptables[type=multiport].

Na prática, o Fail2ban cria uma chain f2b-nginx-npm-scanner, adiciona um salto a partir de DOCKER-USER para TCP/80 e TCP/443 e coloca os IPs banidos nessa chain.

Atenção ao DNAT: quando o pacote chega à DOCKER-USER, o Docker já aplicou o DNAT. Portanto, --dports 80,443 corresponde às portas internas de destino dos containers, não necessariamente às portas originalmente publicadas no host. Se outros containers diretamente expostos também usam internamente 80/443, o mesmo IP banido poderá ser bloqueado para eles. Neste ambiente, o NPM era a entrada web principal e os demais serviços não eram publicados diretamente. Para filtrar pela porta/endereço original do host, a documentação do Docker orienta usar conntrack com os campos de destino original.

Referência: Fail2ban — action iptables.

Passo 4 — manter histórico para o ban progressivo

O incremento do bantime usa o histórico persistido pelo Fail2ban. Mantive 60 dias porque o teto de ban é 30 dias. Como dbpurgeage é global, aumentá-lo também faz a base manter registros de outros jails por mais tempo.

Não altere o /etc/fail2ban/fail2ban.conf distribuído pelo pacote. Crie ou ajuste:

/etc/fail2ban/fail2ban.local
Enter fullscreen mode Exit fullscreen mode
sudo tee /etc/fail2ban/fail2ban.local > /dev/null << 'EOF'
[DEFAULT]
dbpurgeage = 60d
EOF
Enter fullscreen mode Exit fullscreen mode

O próprio projeto recomenda manter customizações em arquivos .local, porque os arquivos .conf podem mudar em atualizações.

Referência: Fail2ban — fail2ban.conf.

Passo 5 — criar o jail do Nginx Proxy Manager

Crie:

/etc/fail2ban/jail.d/nginx-npm.conf
Enter fullscreen mode Exit fullscreen mode
sudo tee /etc/fail2ban/jail.d/nginx-npm.conf > /dev/null << 'EOF'
[nginx-npm-scanner]
enabled      = true
filter       = nginx-npm
backend      = polling
logpath      = /srv/nginxproxymanager/data/logs/proxy-host-*_access.log
ignoreip     = 127.0.0.1/8 ::1 SEU_IP
maxretry     = 5
findtime     = 120

action = iptables[type=multiport, name=nginx-npm-scanner, port="80,443", protocol=tcp, chain=DOCKER-USER, blocktype=DROP]

bantime              = 8h
bantime.increment    = true
bantime.multipliers  = 1 6 21 90
bantime.maxtime      = 30d
EOF
Enter fullscreen mode Exit fullscreen mode

Troque SEU_IP apenas por um IP administrativo fixo e confiável. Se você administra a partir de IP dinâmico, CGNAT ou rede compartilhada, não amplie o ignoreip para uma faixa grande só para evitar autoban.

Detalhe importante do logpath: o Fail2ban resolve o glob dos arquivos existentes quando o jail inicia. Se você criar um novo Proxy Host depois e surgir outro proxy-host-N_access.log, recarregue ou reinicie o jail para que o novo arquivo passe a ser monitorado.

Com essa progressão, o mesmo IP tende a receber:

  • 1ª ocorrência: 8 horas;
  • 2ª ocorrência: 48 horas;
  • 3ª ocorrência: 7 dias;
  • 4ª ocorrência e seguintes: limitadas a 30 dias.

O último multiplicador é reutilizado quando o número de reincidências ultrapassa a lista. Por isso, não use -1 em bantime.multipliers esperando transformar a última ocorrência em ban permanente. Para esse ambiente preferi um teto explícito.

Referência: Fail2ban — jail.conf.

Timezone dos logs

O formato padrão do NPM normalmente inclui o offset na própria linha, por exemplo +0000. Nessa situação o Fail2ban usa o timezone do log e logtimezone não é necessário. Só configure essa opção se o seu formato personalizado não trouxer offset explícito.

Passo 6 — testar a regex antes de ativar o jail

fail2ban-regex \
  "$(ls -t /srv/nginxproxymanager/data/logs/proxy-host-*_access.log | head -1)" \
  /etc/fail2ban/filter.d/nginx-npm.conf
Enter fullscreen mode Exit fullscreen mode

Procure o total de Matched, mas não se limite ao número. Revise algumas linhas casadas pela regex. Uma expressão agressiva que encontra muita coisa pode ser pior do que uma expressão mais curta que acerta somente tráfego claramente malicioso.

Passo 7 — validar a configuração e iniciar

Antes do restart:

sudo fail2ban-client -t
Enter fullscreen mode Exit fullscreen mode

Se a validação passar:

sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-npm-scanner
Enter fullscreen mode Exit fullscreen mode

Depois confira a ligação entre a DOCKER-USER e a chain criada pelo Fail2ban:

sudo iptables -S DOCKER-USER | grep f2b-
sudo iptables -L f2b-nginx-npm-scanner -n --line-numbers
Enter fullscreen mode Exit fullscreen mode

Para acompanhar o serviço:

sudo journalctl -u fail2ban -f
Enter fullscreen mode Exit fullscreen mode

Em instalações que gravam em arquivo, também pode existir:

sudo tail -f /var/log/fail2ban.log
Enter fullscreen mode Exit fullscreen mode

Recriação visual de terminal mostrando fail2ban-client status do jail nginx-npm-scanner e regras na DOCKER-USERExemplo visual de saída esperada, recriado a partir de capturas reais de terminal publicadas em tutoriais técnicos e adaptado ao jail nginx-npm-scanner. Os IPs e contadores da imagem são ilustrativos.

Teste controlado antes de depender de um ataque real

Depois de validar a sintaxe, eu faria um teste manual com um endereço reservado para documentação, sem usar o IP de um cliente real:

sudo fail2ban-client set nginx-npm-scanner banip 192.0.2.10
sudo fail2ban-client status nginx-npm-scanner
sudo iptables -L f2b-nginx-npm-scanner -n --line-numbers

sudo fail2ban-client set nginx-npm-scanner unbanip 192.0.2.10
Enter fullscreen mode Exit fullscreen mode

O endereço 192.0.2.10 pertence ao bloco TEST-NET-1, reservado para exemplos e documentação. O objetivo aqui não é testar uma requisição real, mas confirmar que o Fail2ban consegue criar e remover a regra corretamente.

Se você publica por IPv6 e a chain equivalente existir, faça a mesma validação com um endereço reservado de documentação, como 2001:db8::10.

Como desbanir um IP

sudo fail2ban-client set nginx-npm-scanner unbanip 1.2.3.4
Enter fullscreen mode Exit fullscreen mode

Confirme depois:

sudo fail2ban-client status nginx-npm-scanner
sudo iptables -L f2b-nginx-npm-scanner -n --line-numbers
Enter fullscreen mode Exit fullscreen mode

Rollback: como desativar este jail sem mexer no Docker

Se algo não se comportar como esperado, pare apenas este jail:

sudo fail2ban-client stop nginx-npm-scanner
sudo iptables -S DOCKER-USER | grep f2b- || true
Enter fullscreen mode Exit fullscreen mode

A action nativa remove o salto e a chain que ela criou para o jail. Não é necessário reiniciar Docker nem sair apagando regras manualmente.

Depois corrija a configuração, valide novamente com sudo fail2ban-client -t e só então reinicie ou recarregue o Fail2ban.

Cloudflare: aqui a lógica muda

Se o registro DNS estiver com o proxy da Cloudflare ativo, o servidor de origem recebe a conexão de um IP da própria Cloudflare. O IP do visitante chega em um header HTTP, normalmente CF-Connecting-IP.

Configurar o Nginx para restaurar o IP real é correto para logs e aplicações. Mas isso não altera o endereço de origem do pacote no firewall do Linux. No nível do netfilter, quem abriu a conexão com o seu servidor continua sendo um edge da Cloudflare.

Essa diferença é importante:

Visitante: 200.0.0.10
        ↓
Cloudflare: 172.64.x.x
        ↓
Servidor

Log do Nginx com realip: 200.0.0.10
Origem do pacote no DOCKER-USER: 172.64.x.x
Enter fullscreen mode Exit fullscreen mode

Diagrama mostrando a diferença entre o IP real no log do Nginx e o IP da Cloudflare visto no DOCKER-USERCom a Cloudflare em modo proxy, o Nginx pode registrar o IP real do visitante via CF-Connecting-IP, enquanto o firewall do origin continua vendo a conexão vinda de um IP da rede da Cloudflare.

Se o Fail2ban extrair 200.0.0.10 do log e criar um DROP para esse endereço na DOCKER-USER, a regra não vai casar com o pacote, porque o pacote chega do IP da Cloudflare.

Se, ao contrário, o log estiver registrando um IP da Cloudflare e o Fail2ban banir esse endereço, você pode bloquear um edge legítimo. As requisições que chegarem por esse caminho podem passar a expirar e resultar em erros de conexão com a origem, inclusive 522.

Também não considero correto resolver isso simplesmente colocando todos os ranges da Cloudflare em ignoreip. Isso evita que o Fail2ban derrube os edges, mas, se o log continuar mostrando somente IPs da Cloudflare, o jail deixa de ter como identificar o visitante agressor.

Para domínio proxied, a estratégia deve mudar de camada: restaure o IP real no log e faça o ban na própria Cloudflare, via API/action apropriada do Fail2ban, ou aplique o bloqueio no nível HTTP do proxy. Versões atuais do Fail2ban incluem actions como cloudflare-token; antes de usar, confira o arquivo disponível no seu pacote:

ls -1 /etc/fail2ban/action.d/cloudflare*
Enter fullscreen mode Exit fullscreen mode

Eu não misturaria as duas arquiteturas no mesmo jail sem testar cuidadosamente. Para tráfego direto ao origin, DOCKER-USER funciona bem. Para Cloudflare proxied, o ban deve acontecer onde o IP real ainda existe como origem utilizável: na borda da Cloudflare ou na camada HTTP.

Referências: Cloudflare — Restoring original visitor IPs e Fail2ban — cloudflare-token.

Fail2ban não bloqueia o Docker? Checklist rápido

  • DOCKER-USER existe?sudo iptables -S DOCKER-USER
  • O filtro está encontrando eventos?Rode novamente o fail2ban-regex contra um access log real.
  • O logpath existe?ls -lh /srv/nginxproxymanager/data/logs/proxy-host-*_access.log
  • O jail está ativo?sudo fail2ban-client status nginx-npm-scanner
  • A chain do Fail2ban foi ligada à DOCKER-USER?sudo iptables -S DOCKER-USER | grep f2b-
  • O IP entrou na chain f2b?sudo iptables -L f2b-nginx-npm-scanner -n --line-numbers
  • Existe Cloudflare no caminho?Se sim, não espere que um DROP para o IP lógico do visitante funcione no firewall do origin.

Esse roteiro separa três problemas que parecem iguais: a regex não detectou, o Fail2ban detectou mas não executou a action, ou a action funcionou em um ponto que não está vendo o IP que você quer bloquear.

Cuidados finais

  • Não libere bots por User-Agent e não ative o jail sem revisar o resultado do fail2ban-regex.
  • Não crie DOCKER-USER manualmente em host com backend nftables nativo só para seguir este procedimento.
  • Não copie logtimezone nem ignoreip sem validar o seu ambiente.
  • Com Cloudflare proxied, não tente combater o usuário final bloqueando IPs da própria Cloudflare no origin.

Hardening complementar

Fail2ban é uma camada adicional. Ele não substitui atualização do host, restrição de painéis administrativos, autenticação forte e redução da superfície exposta.

Se o servidor também aceita SSH administrativo, veja o guia de hardening de SSH com chave Ed25519.

Para operação do ambiente, também deixei um cheat sheet de Docker e Docker Compose. E, se ainda estiver definindo a arquitetura do serviço, vale comparar Docker e máquinas virtuais.

Perguntas frequentes

Fail2ban funciona com Nginx Proxy Manager em Docker?

Sim. Neste cenário ele roda no host, lê os access logs persistidos pelo NPM e aplica o ban no firewall do host.

Por que o UFW pode bloquear o IP e o container continuar acessível?

Porque o Docker desvia o tráfego das portas publicadas pelas regras de NAT antes do fluxo normal usado pelo UFW.

Posso usar o mesmo procedimento com Docker nftables?

Não diretamente. No backend nftables nativo não existe DOCKER-USER; use uma tabela/base chain própria seguindo a prioridade documentada pelo Docker.

E se o domínio estiver proxied pela Cloudflare?

O log pode mostrar o IP real via CF-Connecting-IP, mas o firewall do origin recebe a conexão de um IP da Cloudflare. Nesse caso, prefira o ban na borda da Cloudflare ou na camada HTTP.

Fontes técnicas


Originally published at https://tecmestre.com.br

Top comments (0)