O limite de login do seu dashboard podia ser burlado com um único header

Você passou uma tarde inteira colocando o dashboard web do Hermes atrás do nginx, definiu uma senha, ligou o HTTPS e expôs a porta para a internet. Disse a si mesmo que estava tudo bem: o endpoint de login permite dez tentativas por minuto, então um ataque de força bruta é matematicamente inviável. Aí você descobre que basta o atacante enviar um valor diferente de X-Forwarded-For em cada requisição para ter tentativas ilimitadas — porque o rate limit é contado por “client IP”, e o client IP veio de um header que o próprio atacante escreveu.
Em 5 de outubro de 2026, o repositório do Hermes mesclou três correções de hardening para a autenticação do dashboard em um único dia (PRs #133367, #133369, #133370). Nenhuma delas é uma funcionalidade. As três são a mesma classe de erro no mesmo ponto de entrada: entrada sem teto, confiada só porque chegou. Veja a seguir o que era cada uma, por que importava, como é o comportamento corrigido e se a sua própria implantação precisa mudar alguma configuração.
O que o rate limit deveria fazer
O login por senha do dashboard fica em /auth/password-login. Ele tem rate limit por client IP, dez tentativas por minuto por padrão, retornando HTTP 429 acima disso. Um usuário inexistente e uma senha errada devolvem a mesma resposta genérica 401 Invalid credentials, de modo que o endpoint não pode ser usado como oráculo de enumeração de usuários.
O desenho está correto. O problema era de onde vinha o “client IP”.
Um: o client IP era o que o cliente dissesse que era
client_ip() em hermes_cli/dashboard_auth/request_utils.py retornava o primeiro valor do header X-Forwarded-For da requisição. Esse header é, por construção, informação escrita na cadeia por outra pessoa — e um cliente pode forjá-lo. Logo:
- enviar um valor diferente de
X-Forwarded-Forem cada requisição gerava um novo bucket de rate limit, tornando o teto de dez por minuto inútil; - o teto de pendências por IP no fluxo de sign-in nativo podia ser contornado da mesma forma;
- o endereço de origem registrado no log de auditoria de autenticação virava o que o atacante quisesse escrever.
A correção é direta: client_ip() agora retorna apenas o peer do ASGI (request.client.host) e nunca lê o header bruto. E o caso do reverse proxy? O ProxyHeadersMiddleware do Uvicorn reescreve esse peer a partir do X-Forwarded-For só quando a requisição chega de fato de um proxy confiável — loopback, ou um endereço listado em dashboard.trusted_proxies — e pega o salto mais à direita que não é confiável, então um reverse proxy normal ainda dá a cada cliente real o seu próprio bucket.
Dois: um campo podia encher o seu disco
O POST /auth/password-login não exige autenticação. Ele copiava a string provider da requisição, literalmente, para o dashboard-auth.log, tanto no caminho de provider desconhecido (404) quanto no de rate limit (429). Uma única requisição podia, portanto, gravar uma entrada de log de qualquer tamanho: a própria sondagem do PR mostra um provider de 100.000 caracteres deixando cerca de 100 KB no log antes da correção, e 336 caracteres depois.
Agora o provider tem um teto de 128 caracteres. O FastAPI rejeita valores maiores com um 422 antes de o handler rodar, então nada é registrado no log. Além disso, audit_log() limita todo valor de string a 256 caracteres — incluindo valores aninhados e o repr dos que não são JSON — de modo que um campo de auditoria futuro não consiga reintroduzir a mesma escrita sem limite. Há exatamente um efeito colateral: um provider de senha customizado cujo ID passe de 128 caracteres não conseguirá mais fazer login. Os IDs de provider já incluídos (basic, nous, self-hosted, drain-secret) são bem mais curtos.
Três: as rotas públicas de autenticação não tinham limite de tamanho de corpo
As três rotas POST públicas sob /auth/ — /auth/password-login, /auth/native/token, /auth/native/refresh — passam pelo portão de autenticação, e depois disso o FastAPI coloca todo o corpo JSON em buffer antes de validá-lo. Não havia limite de tamanho, então um cliente anônimo podia fazer o dashboard segurar na memória um upload grande ou que nunca termina.
Um novo AuthBodyLimitMiddleware, ASGI puro, impõe um orçamento de 64 KiB nos POSTs públicos de /auth/:
- se o
Content-Lengthpassa do limite, ele responde 413 antes de ler um único byte do corpo; - ele também conta os bytes que de fato recebe, então um corpo em chunked, ou um com
Content-Lengthsubestimado, é cortado no mesmo limite.
O middleware é registrado no nível mais interno — nenhum middleware HTTP externo lê corpos de requisição — então o limite vale antes de qualquer buffer. Rotas autenticadas, uploads e WebSockets ficam inalterados.
O que a sua própria implantação precisa mudar
As três correções são comportamento do lado do servidor; não há código para você escrever. A única coisa que vale checar é dashboard.trusted_proxies: só os peers listados ali (mais o loopback, que é sempre confiável) podem fornecer o X-Forwarded-For. Entradas sem limite como *, 0.0.0.0/0 e ::/0 são rejeitadas.
dashboard:
public_url: "https://dashboard.example.com/hermes"
trusted_proxies:
- "172.20.0.0/24"
Se você não listar o seu reverse proxy, os clientes atrás dele não conseguem apresentar o próprio endereço, então todos os clientes atrás desse proxy compartilham um único orçamento de login por senha e os eventos de auditoria gravam o endereço do proxy. Isso erra para o lado seguro — um bucket compartilhado é melhor que um forjável —, mas deixa o rate limit impreciso, então prefira um IP de proxy exato, ou uma rede limitada e exclusiva de proxies.
Existe um jeito tosco, mas eficaz, de verificar: bata no endpoint de login onze vezes e espere 429 na décima primeira. Depois faça de novo com um X-Forwarded-For forjado em cada requisição; a décima primeira ainda deve ser 429. Se a segunda rodada passar direto, a sua instância ainda tem o comportamento antigo.
Status da release
As três correções estão na main e ainda em nenhuma tag publicada (a release mais recente é a v0.21.5, tag v2026.9.24). Instalações via Git as recebem ao puxar a main mais recente; imagens Docker e implantações hospedadas as recebem quando a próxima tag for construída. Você pode confirmar o que está rodando com hermes version, e o panorama completo do login do dashboard e da configuração de proxy está na referência do comando dashboard.
O padrão por trás das três
As três correções têm o mesmo formato: entrada não autenticada usada como se fosse confiável. Um endereço de origem declarado pelo cliente tratado como identidade, uma string enviada pelo cliente tratada como campo delimitado, um corpo mandado pelo cliente tratado como de tamanho razoável. A mesma classe aparece em todo serviço exposto à internet, e a correção é sempre a mesma: confie só no que o seu próprio lado observou e ponha um teto em tudo que chega.
Para completar, o mesmo dia também trouxe correções sobre fronteiras de credenciais e de arquivos — vale ler junto com estas a exportação de perfil que podia enviar seus segredos e a fronteira de acesso entre profiles.