O Hermes não busca mais qualquer URL que alguém lhe entregue

Você roda um serviço de geração de imagens no servidorzinho ali do corredor e aponta o base_url de um provider para ele. Um dia o agente gera uma imagem e a “URL da imagem” que volta é http://127.0.0.1:8080/admin/... — e o Hermes, prestativamente, faz o fetch e guarda o resultado como imagem. Ou pegue o roteiro pior: a URL do resultado aponta para 169.254.169.254, o serviço de metadados da instância na nuvem, e as credenciais IAM temporárias da máquina passeiam por um caminho que parece exatamente o download de uma figura.
Essas URLs compartilham uma propriedade: você não as escolheu. Elas chegam da resposta de um provider, de um campo que o próprio modelo preencheu ou de uma entrada em um índice de terceiros — a superfície de ataque canônica. O PR #114887, mesclado em 18 de setembro de 2026, liga sete desses pontos de fetch ao guard de SSRF que o repositório já possuía.
A forma do problema: sete pontos de fetch ainda usando clientes crus
O Hermes já tinha verificações de segurança de URL (tools/url_safety.py), usadas pelos downloads de mídia de plataformas, pelas ferramentas web e pelos caminhos do browser. Mas sete outros lugares chamavam requests.get, httpx.get ou urllib.request.urlopen diretamente, sobre URLs sobre as quais não tinham voz:
agent/provider_media.py::save_url— a URL de resultado que um provider de geração devolve, compartilhada porsave_url_imageesave_url_video;- os dois pontos de fetch dentro dos plugins de geração de imagem da OpenAI e do OpenAI-Codex que transformam uma imagem remota em bytes ou em data URL;
- o
_http_get/_downloadda pet store e o fetch do manifest do petdex; - o caminho remoto-imagem-para-data-URL do gateway da TUI;
- o fetch do catálogo via sitemap do skills.sh.
Uma porta tinha cadeado. As sete janelas ao lado não — que é o modo de falha mais comum e mais instrutivo no trabalho de segurança.
A correção: três verificações, cada uma mais difícil de driblar que a anterior
Nada novo foi inventado. O guard existente foi aplicado por completo, e os três bypasses fáceis foram fechados:
- Verificação antes do fetch. Todo ponto roda
is_safe_urlprimeiro; um endereço recusado nunca abre conexão, e o erro diz o que aconteceu:Blocked request to private/internal address: 127.0.0.1. - Nova verificação no momento do connect. Os clientes são construídos com
create_ssrf_safe_client, que reaplica a política no connect TCP e disca para o IP validado, preservando Host/SNI. Isso fecha a janela de DNS rebinding (TOCTOU): se a primeira resolução devolve um endereço público e a segunda devolve um interno, uma implementação que só checa antes do fetch cai direto na armadilha. - Um loop de redirect manual e limitado. No máximo cinco saltos, cada um revalidado — e um 3xx sem header
Locationagora falha fechado em vez de ser cacheado como imagem. Headers fornecidos pelo chamador (digamos, a bearer key de vídeo do OpenRouter) ficam restritos ao primeiro salto apenas, então um redirect para outro domínio não pode levá-los consigo.
As classes de endereço que o guard recusa (da documentação oficial):
- Redes privadas (RFC 1918):
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Loopback:
127.0.0.0/8,::1 - Link-local:
169.254.0.0/16, que inclui os metadados de cloud em169.254.169.254 - CGNAT / espaço de endereçamento compartilhado (RFC 6598):
100.64.0.0/10— o que as VPNs Tailscale e WireGuard entregam - Hostnames de metadados de cloud:
metadata.google.internal,metadata.goog - Endereços reservados, multicast e não especificados
Dois padrões definem o tom: falhas de DNS contam como bloqueio (fail-closed) e cada salto de uma cadeia de redirect é revalidado.
Se você realmente precisa de URLs internas, há duas opções de adesão
Isso não é uma proibição geral, e a documentação traça a linha com cuidado:
# Opt-out global (padrão: false) — uma decisão explícita de limite de confiança
security:
allow_private_urls: true
# Mais restrito: apenas o próprio bloco fake-ip do proxy local
security:
fake_ip_ranges:
- 198.18.0.0/15
allow_private_urlsserve para redes domésticas que resolvemhome.arpapara o espaço RFC 1918, endpoints de Ollama ou llama.cpp só na LAN, wikis internas. A documentação é direta sobre o que isso significa: é um limite de confiança deliberado — ative apenas onde “o agente nesta máquina pode sofrer prompt injection e acabar acessando a rede local” é um risco aceitável, e deixe desligado em gateways expostos publicamente. Um detalhe que vale notar: o guard de substring de host que bloqueia domínios Unicode parecidos continua ativo de qualquer forma, independentemente dessa configuração.fake_ip_rangesé para proxies TUN em modo fake-ip (fake-ip do Mihomo/Clash, Surge enhanced). Esses proxies respondem a todo nome fora do seu filtro com um endereço do próprio bloco (198.18.0.0/15por padrão), então o guard de endereços privados rejeitaria todo fetch de saída com “URL targets a private or internal network address”, enquanto a requisição nunca chega à rede. Declarar o bloco libera apenas esse bloco; destinos loopback, RFC 1918, link-local, CGNAT e de metadados continuam bloqueados. Uma entrada que se sobreponha a qualquer uma dessas classes —0.0.0.0/0ou::/0— é ignorada com um aviso em vez de alargar o guard silenciosamente.
Há mais uma exceção que importa no dia a dia: o seu próprio base_url de provider não é “a URL de outra pessoa”. Um download buscado diretamente do base_url configurado (o endpoint de conteúdo de vídeo do OpenRouter é o exemplo) pula a checagem da classe de endereços privados nesse primeiro salto, mas o piso de metadados de cloud continua valendo, e qualquer redirect que ele emita é revalidado por inteiro. Então, se você hospeda um provider de geração de imagens na sua LAN e quer que as URLs de resultado dele sejam cacheadas localmente, é exatamente para esse caso que existe security.allow_private_urls: true.
O que o PR mediu: nove de nove acertavam antes, zero depois
A verificação vale ser citada porque é do tipo que de fato prova algo. Um listener em loopback registra cada conexão, e o caminho real de código aciona cada ponto de chamada:
| Cenário | Antes | Depois |
|---|---|---|
http://127.0.0.1:PORT/... nos nove pontos |
o listener registrou um acerto em todos os pontos; os corpos eram devolvidos e cacheados | zero acertos; cada ponto levantou seu próprio tipo de erro (_image_to_data_url retorna None) com Blocked request to private/internal address: 127.0.0.1 |
um primeiro salto seguro, depois 302 para 169.254.169.254 |
uma tentativa real de conexão | esse salto é recusado |
um 3xx sem header Location |
o corpo do 3xx era cacheado como imagem | falha fechada |
O que fazer na sua própria instalação
Três passos práticos, do mais importante para o menos:
- Em um gateway exposto publicamente, confirme que o opt-out está desligado. Rode
hermes config get security.allow_private_urls; não definido significa o padrão, que é desligado. Deixar ligado depois de um experimento pontual com um serviço interno é um erro comum e muito silencioso. - Se você roda geração de imagens ou um modelo local na sua LAN, resista à tentação de virar o switch global primeiro. Se o que você precisa é apenas que as URLs de resultado de um provider sejam cacheadas localmente, é esse o botão. E se você está atrás de um proxy TUN fake-ip, prefira o
fake_ip_ranges, mais restrito. - Some bom senso em máquinas na nuvem. Este guard é uma porta que não dá para contornar, mas credenciais de metadados de instância também podem ser alcançadas por comandos de terminal — e esse lado é tratado por um portão de aprovação, que cobrimos no post sobre o portão de aprovação do 169.254.169.254. Os dois juntos formam o quadro completo.
Leitura relacionada: a correção de esquema fechado das credenciais do catálogo MCP (entradas de terceiros não podem conseguir escrever variáveis de ambiente arbitrárias) e o modelo de revisão do catálogo oficial de MCP remoto. A documentação de segurança em si fica em hermes-agent.nousresearch.com/docs/user-guide/security.
Status de release
O PR #114887 entrou na main em 18 de setembro de 2026, e a tag mais recente continua sendo a v0.21.3 (nome de tag v2026.9.14, lançada em 14 de setembro), então ele ainda não está em uma release marcada. Uma instalação via git o pega na próxima atualização:
hermes --version
hermes update
Quem usa imagem Docker ou cloud recebe com a próxima tag.
A lição
Resumindo no essencial, a regra é esta: um guard vale pelo número de entradas que ele cobre, não por quão rígido ele foi escrito. Sete pontos de fetch ainda usavam clientes HTTP crus, e pelo menos duas classes de ataque — DNS rebinding e um 3xx sem Location — derrotam de imediato o padrão de checar só antes do fetch. A forma robusta faz as três coisas ao mesmo tempo: valida na entrada, valida de novo no connect, valida cada salto — e dá às pessoas que legitimamente precisam de acesso interno um switch explícito, auditável e o mais estreito possível.