Uma URL, dois hosts: fechando a lacuna entre parsers no guard SSRF do Hermes

Alguém manda um link na sua DM e você repassa para o agente: busca isso, dá uma olhada, me diz se importa. Funciona, o resumo chega, nada parece estranho. Por trás desse sucesso banal, dois pedaços de código discordavam sobre para onde o link apontava — a checagem que decide “esse endereço é permitido” e o cliente que de fato abriu o socket. A checagem achava que era um IP público. O navegador lia os mesmos caracteres como uma porta na sua própria máquina. O PR #145578, mergeado em 27 de outubro, fecha essa lacuna: quando uma URL tem duas leituras plausíveis, o Hermes agora a recusa em vez de escolher uma.
A checagem e a conexão estavam lendo strings diferentes
Antes de buscar qualquer coisa, o Hermes faz uma triagem do destino: loopback, faixas privadas e endpoints de metadata de cloud deveriam estar fora de alcance. Essa lógica mora em tools/url_safety.py, com is_safe_url() como decisor principal e is_always_blocked_url() como uma barreira mais rígida.
O código de triagem parseia a URL com o urllib.parse do Python — mais especificamente o urlsplit. O que acaba abrindo a conexão muitas vezes não faz isso. A browser tool roda sobre o Chromium. Backends de fetch baseados em Node (um Firecrawl self-hosted, por exemplo) e o urllib3 seguem convenções próprias. Navegadores seguem o padrão WHATWG URL. Entregue a mesma string para as duas stacks e você pode obter dois hosts diferentes — e o host é a única coisa que a checagem de segurança olha.
Quatro URLs, duas respostas
O PR traz quatro casos para você conferir diretamente:
| URL | Host pelo urlsplit / httpx | Host pelo WHATWG | Resultado na main |
|---|---|---|---|
http://127.0.0.1:8080\@1.1.1.1 |
1.1.1.1 |
127.0.0.1:8080 |
is_safe_url retorna True |
http://%31%32%37.0.0.1/ |
%31%32%37.0.0.1 |
127.0.0.1 |
depende de DNS (True atrás de proxy quando o DNS falha) |
http://***@1.1.1.1/ |
1.1.1.1 |
169.254.169.254 |
is_always_blocked_url retorna False |
http:\\169.254.169.254\ |
nenhum | 169.254.169.254 |
is_always_blocked_url retorna False |
A primeira linha é a ilustração mais clara. O urlsplit vê o @, decide que tudo antes dele é um par de credenciais e reporta o host como 1.1.1.1 — público, tudo certo, pode ir. O Chromium encerra a authority na barra invertida e conecta alegremente em 127.0.0.1:8080. Um endereço que passou como “na internet” aterrissa numa porta de loopback.
A segunda linha é um host com percent-encoding. A checagem vê o texto literal %31%32%37.0.0.1; o navegador decodifica para 127.0.0.1. Na main o veredito chega a depender de DNS: com proxy configurado e a resolução falhando, a função retorna True.
As linhas três e quatro pegam outro caminho. is_always_blocked_url() não bloqueava nenhuma das duas — e é justamente nessa função que confiam os caminhos que pulam o is_safe_url(): o backend Chromium local e o sidecar híbrido. A última linha de defesa para endereços de metadata tinha o mesmo furo.
Onde as duas leituras divergem
O WHATWG faz três coisas que o urlsplit não faz:
- Barra invertida é barra normal. Para esquemas especiais como http, uma barra invertida equivale a
/, e a authority termina na primeira delas. Então a authority emhttp://127.0.0.1:8080\@1.1.1.1é apenas127.0.0.1:8080. - O host é percent-decodificado.
%31%32%37é127. - Barras faltantes são corrigidas.
http:\\hostehttp:///hostnormalizam os dois parahttp://host.
O urlsplit não faz nada disso. Não trata barra invertida de forma especial, não decodifica o host, e trata um // ausente como “não existe authority nenhuma aqui”. O que significa que “essa URL aponta para dentro da minha rede?” não tem resposta única — depende de quem você pergunta. A checagem de segurança perguntou para uma stack; o cliente usou outra. O espaço entre elas é o bypass.
A correção: recusar a ambiguidade em vez de resolvê-la
Em vez de ensinar um parser a imitar o outro, o patch segue o caminho oposto: se duas stacks podem ler hosts diferentes da mesma string, trate como inseguro. O nome disso é fail closed — rejeitar um endereço que talvez fosse legítimo, em vez de adivinhar qual leitura o cliente vai usar.
Isso vive num helper novo, _authority_ambiguity(), na linha 253 de tools/url_safety.py. Para URLs http(s), ele tira espaços em branco comuns no início e no fim (espaço, tab, CR, LF, VT, FF) e então rejeita a URL se qualquer uma destas condições valer:
- Um caractere de controle C0 ou DEL aparece em qualquer posição — inclusive no fim, como em
http://169.254.169.254\x01, porque ourlsplitdescarta tabs, CR e LF silenciosamente, então os mesmos bytes podem significar coisas diferentes dos dois lados. - A authority
//está faltando:http:\\host,http:///host,http:host. - Uma barra invertida aparece em qualquer lugar da authority.
- A authority contém
@(userinfo, que ois_safe_url()já rejeitava de saída). - O host contém
%ou espaço em branco. - A porta é inválida — não numérica ou fora da faixa.
O is_safe_url() chama isso logo depois da checagem de esquema. Quando rejeita, o log registra apenas o motivo, nunca a URL — URLs podem carregar credenciais, e um arquivo de log é só mais um lugar para vazá-las.
Por que o fallback ficou mais frouxo, não mais rígido
is_always_blocked_url() (linha 342) atende os backends que nunca chamam is_safe_url(): o Chromium local e o sidecar híbrido. Ele rejeita só o que pode fazer um cliente alcançar um host diferente: caracteres de controle, authority // ausente, barra invertida na authority. Hosts com percent-encoding são julgados depois da decodificação, então http://%31%36%39.254.169.254/ é pego.
Portas inválidas, espaço em branco no host e userinfo não mudam o host com que o cliente acaba falando, então o fallback os ignora de propósito. O motivo é concreto: o browser_exec monta suas próprias URLs a partir de template literals — f"http://localhost:{port}/", "http://localhost:%d/" e um `http://localhost:${port}/api` em JS — e um fallback estrito quebraria todos eles no backend de navegador local. O rigor fica onde deve ficar, no is_safe_url(); o fallback protege a única propriedade que importa, isto é, que o host não possa ser lido de duas formas.
Mais um detalhe fácil de perder: as duas funções pegam o host da mesma string já aparada que usaram para a checagem de caracteres de controle, e então comparam o host em IDNA contra o endereço de metadata após mapear U+3002, U+FF0E e U+FF61 para .. O efeito prático é que http://169.254.169.254。/, formas montadas com pontos full-width ou half-width e dígitos full-width resolvem todos para 169.254.169.254 para esse fim — mesmo quando o DNS falha e há proxy configurado.
URLs que não são http(s), como about: e data:, ficam intocadas pela mudança.
Dois conceitos que vale ter claros
SSRF — server-side request forgery. Você pede que um servidor busque uma URL em seu nome; um atacante monta essa URL para que o servidor alcance algo que ele mesmo não alcançaria: uma porta de loopback, um painel administrativo interno, uma API de metadata de cloud. Seu serviço vira o proxy dele e o ponto de apoio dele dentro da rede. O que torna isso desagradável é que a requisição realmente sai de dentro, então regras de perímetro não têm do que reclamar — o chamador é, por definição, “um dos nossos”.
169.254.169.254 é o endereço link-local que provedores de cloud expõem em toda instância como serviço de metadata. Ele serve mais do que hostname e região; tipicamente também entrega credenciais temporárias no nível da instância. Quem conseguir convencer um servidor a requisitar esse endereço pode sair com a identidade cloud da máquina, e o que essa identidade pode fazer depende do role anexado à instância. É por isso que ele é tratado como uma classe separada de endereço, nunca permitida, em vez de ser dobrado na checagem genérica de faixas privadas.
Junte as duas coisas e você tem este bug: uma URL que convence o verificador de que aponta para a internet pública, e um host que o cliente lê como loopback ou como metadata.
O que fazer com isso
- Mantenha-se atualizado.
hermes update. A mudança entrou namainem 27 de outubro, e o PR observa explicitamente que não há issue associada — é hardening proativo, não patch de um exploit conhecido em campo. - Espere a nova semântica: ambíguo significa recusado. Se você tem automação que constrói URLs espertas desse formato de propósito, ela agora será recusada. Essa é a intenção, não uma regressão; URLs comuns não são afetadas.
- O padrão se generaliza. A correção anterior que estabeleceu que loopback não é fronteira de confiança para a porta A2A parte da mesma ideia: uma decisão de segurança vale tanto quanto o acordo dela com o componente que de fato executa a ação.
- Se você se importa com as superfícies que um agente alcança para fora, leia a orientação de segurança do catálogo MCP junto com a superfície de controle da extensão de navegador — a primeira trata do que você instala, a segunda de quanta autoridade o lado do navegador detém. Este bug ficava exatamente onde as duas se encontram.
A lição, se você quiser em uma linha: o host que o seu validador lê precisa ser o host que o seu cliente disca. Quando isso não pode ser garantido, fail closed é a única resposta honesta.