← All posts

Todo controle por IP que lançamos era contornável com um header

Authagonal·July 11, 2026
securityrate-limitinginfraaks

Nosso contador de bloqueio de conta limitava logins falhos a cinco. Nosso rate limiter estrangulava clientes abusivos. Os dois usavam o endereço IP do cliente como chave, que é a coisa óbvia para usar como chave. Os dois eram completamente contornáveis, e o bypass era um único header HTTP que você mesmo podia definir.

O motivo é este. Quando seu app roda atrás de um reverse proxy, e o nosso roda atrás do nginx no Kubernetes, a conexão TCP que o app vê não vem do usuário. Vem do proxy. Toda request chega com o mesmo endereço de peer no socket: o ingress. Então o IP real do cliente precisa ser carregado em um header, X-Forwarded-For, que o proxy define e o app lê. O ASP.NET Core tem middleware exatamente para isso: ele lê o X-Forwarded-For e reescreve o IP remoto da conexão para corresponder, de modo que o resto do seu código, incluindo o rate limiter, veja o cliente verdadeiro.

O problema é que X-Forwarded-For é só um header. Qualquer um pode mandar um. Se o app confia nele incondicionalmente, e o nosso confiava, aceitando de qualquer origem, então o valor é controlado pelo atacante. Então o ataque não é nada engenhoso, é uma linha:

curl -H "X-Forwarded-For: 10.0.0.1" https://.../login
curl -H "X-Forwarded-For: 10.0.0.2" https://.../login
curl -H "X-Forwarded-For: 10.0.0.3" https://.../login

Troque o header a cada request e o app acha que cada tentativa vem de um cliente novinho em folha. O contador de bloqueio, chaveado nesse IP, nunca chega a cinco para nenhum valor individual. O bucket do rate limiter está zerado toda vez. Dá para fazer brute force numa senha o dia inteiro, e cada defesa por IP que tínhamos conta alegremente até um. Um controle chaveado num valor que o atacante define não é um controle.

A correção parece trivial: só confiar no X-Forwarded-For vindo do seu próprio proxy. É trivial em princípio e chata na prática, e o Kubernetes é onde a chatice mora.

Você não pode simplesmente parar de ler o header. Se fizer isso, toda request parece vir do ingress, então agora todos os seus clientes compartilham um IP e os controles por IP quebram na direção oposta: um abusador dispara o bloqueio para todo mundo. Você precisa ler o X-Forwarded-For, mas só acreditar nele quando a request realmente veio de um proxy em que você confia.

O middleware de forwarded headers do ASP.NET confia por origem: uma lista de IPs de proxy conhecidos, ou redes de proxy conhecidas dadas como CIDRs. O movimento ingênuo é fixar o IP do pod do ingress. No AKS esse IP não é estável. Pods de ingress são reagendados e reescalados, e os endereços deles mudam junto, então um IP fixado fica obsoleto e ou quebra tráfego legítimo ou, se você deixar um fallback antigo lá, reabre o buraco. O que é estável é a subnet de onde os pods tiram endereços. Então você confia no CIDR da subnet dos nós, não em nenhum endereço individual. Todo salto que pode legitimamente definir o header vive nessa faixa, e nada fora dela merece crédito.

Depois vem a contagem. O middleware percorre a lista do X-Forwarded-For da direita para a esquerda, descascando um salto confiável de cada vez, e o que sobra depois dos saltos confiáveis é tomado como o cliente. Ande um salto a mais e você sai do seu proxy e entra na parte do header que o atacante escreveu. Então o número de saltos que você descasca tem que ser igual ao número de proxies que de fato acrescentam entradas ao header, exatamente. No nosso caminho há um: o nginx. O load balancer da Azure na frente dele é layer 4. Ele encaminha TCP, não faz parse de HTTP e não adiciona nenhuma entrada ao X-Forwarded-For. Então o forward limit correto é um. Não o padrão, não "alguns por garantia". Um, porque um proxy toca nesse header.

Esse par, confiar na subnet dos nós e descascar exatamente um salto, é a correção inteira. O IP de cliente que o middleware te entrega agora é o valor que o nginx escreveu a partir do socket real, e as entradas injetadas pelo atacante ficam à esquerda dele, ignoradas, porque paramos de andar antes de chegar nelas.

A parte transferível não são os números, é que eles são números sobre a sua topologia específica. X-Forwarded-For não é dado, é delegação de confiança: lê-lo é dizer "eu acredito em quem definiu isto". Essa crença tem que estar presa aos saltos exatos do seu caminho de request, nem mais larga nem mais estreita. Confie largo demais e vira spoof. Confie estreito demais e você colapsa todos os seus clientes no proxy. E a contagem está acoplada à sua infraestrutura, então no dia em que você colocar um CDN na frente de tudo, você adicionou um salto, e o forward limit que estava correto ontem agora está um a menos. A correção não é uma constante mágica. É: conte os proxies no seu caminho, confie neles e só neles, e reconte toda vez que o caminho mudar.

Contar saltos de proxy corretamente é um trabalho sem glamour, fácil de errar, e decide se os seus rate limits significam alguma coisa. Authagonal faz essa contabilidade por você, para que o seu bloqueio conte o cliente, e não um header.