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 por save_url_image e save_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 / _download da 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:

  1. Verificação antes do fetch. Todo ponto roda is_safe_url primeiro; 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.
  2. 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.
  3. Um loop de redirect manual e limitado. No máximo cinco saltos, cada um revalidado — e um 3xx sem header Location agora 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 em 169.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_urls serve para redes domésticas que resolvem home.arpa para 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/15 por 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/0 ou ::/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:

  1. 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.
  2. 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.
  3. 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.