Ir para o conteúdo
CodeAuditAgent
Todos os artigos

Defesa contra SSRF em webhooks, prévias de link e buscadores de URL

Como o SSRF transforma webhooks, prévias de link e importadores num caminho até metadados de nuvem e serviços internos, e as defesas que funcionam.

· 7 min de leitura · Lina Source LLC

Qualquer recurso que receba uma URL de um usuário e a busque a partir do seu servidor é um potencial server-side request forgery (SSRF, CWE-918). Webhooks, prévias de link, avatar por URL, importações de RSS e calendário, renderizadores de PDF e botões de “importar de uma URL” compartilham o mesmo formato: seu servidor faz uma requisição a um destino escolhido por outra pessoa.

O problema é onde seu servidor está. Ele alcança coisas que o atacante não alcança: o serviço de metadados da nuvem, painéis administrativos internos, bancos de dados sem senha numa rede privada e serviços em localhost. O SSRF transforma seu servidor no proxy do atacante para dentro dessa rede. É especialmente fácil de introduzir em times pequenos, porque o recurso que causa o problema parece inofensivo: um campo de URL numa página de configurações, um cartão de prévia num chat, um helper que baixa uma foto de perfil. Nada disso parece código de segurança de rede, então raramente é revisado como tal.

O que o atacante busca

  • Endpoints de metadados de nuvem, o mais famoso deles 169.254.169.254 na AWS, GCP e Azure, que podem devolver detalhes da instância e, em algumas configurações, credenciais temporárias do papel da máquina.
  • Serviços escutando em localhost, como interfaces administrativas, servidores de depuração e endpoints de métricas que presumem só receber chamadas locais.
  • Serviços internos em faixas privadas (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) que não têm autenticação porque nunca deveriam ser alcançáveis de fora.
  • Varredura de portas e descoberta de serviços, usando tempos de resposta ou mensagens de erro para mapear a rede interna.
  • Protocolos não HTTP, se a biblioteca de busca suportar esquemas como file:, gopher: ou ftp:.

Mesmo quando a resposta não volta para o atacante, um SSRF cego ainda consegue disparar requisições que alteram estado contra endpoints internos. Webhooks costumam ser cegos, e isso não os torna seguros. Muitos serviços internos aceitam requisições GET simples que mudam estado, como limpezas de cache ou ações administrativas atrás de uma URL, justamente porque presumem que ninguém de fora os alcança.

Por que verificações simples falham

O primeiro instinto é analisar a URL e rejeitar hostnames como localhost ou strings que comecem com 10. ou 192.168. Isso falha por vários motivos.

  • Hostnames resolvem para IPs. Um atacante registra um domínio cujo registro A aponta para 127.0.0.1 ou 169.254.169.254, e uma verificação de hostname não vê nada de errado.
  • Endereços IP têm muitas grafias: decimal (2130706433), hexadecimal, formas curtas como 127.1, e IPv6 com IPv4 mapeado, como ::ffff:127.0.0.1. Comparação de strings deixa tudo isso passar.
  • DNS rebinding: o domínio resolve para um IP público quando você o valida e para um IP privado um instante depois, quando o cliente HTTP conecta. Validar e conectar são duas resoluções separadas.
  • Redirecionamentos: a URL que você validou devolve um 302 para http://169.254.169.254/, e o cliente HTTP o segue sem perguntar nada.

O fio condutor é que a validação acontece sobre algo diferente do endereço a que o socket realmente se conecta. A correção é verificar o IP resolvido no momento da conexão, em toda conexão, inclusive depois dos redirecionamentos.

Defesas, da mais forte para a mais fraca

Use allowlist de destinos quando puder

Se o recurso só precisa conversar com um conjunto conhecido de hosts, como uma integração com um punhado de provedores, coloque esses hostnames em allowlist e rejeite todo o resto. Este é o controle mais forte e o mais fácil de raciocinar a respeito. Compare o hostname analisado por igualdade exata, não com verificações de startsWith ou endsWith, que aceitam hosts parecidos como api.example.com.attacker.net ou evilexample.com. Tudo o que vem abaixo é para recursos que precisam aceitar URLs públicas arbitrárias.

Restrinja esquema e porta

Aceite apenas https: (e http: só se for inevitável). Rejeite credenciais na URL e restrinja as portas a 443 e 80, a menos que haja uma necessidade clara. Analise com um parser de URL padrão, nunca com regex; o parser da WHATWG também normaliza grafias estranhas de IP, o que ajuda nas verificações seguintes.

Resolva e verifique o IP no momento da conexão

Bloqueie as faixas de loopback, privadas, link-local, NAT de operadora, não especificadas e as faixas IPv6 unique-local e link-local. O mais importante: aplique a verificação dentro da resolução DNS que o cliente HTTP usa, para que o endereço validado seja o endereço conectado. Isso fecha a brecha do DNS rebinding. Os módulos http e https do Node aceitam uma função lookup customizada exatamente para isso.

A lista de bloqueio abaixo cobre as faixas que importam para a maioria das implantações. Acrescente quaisquer faixas públicas que pertençam à sua própria infraestrutura, já que um load balancer ou uma API interna com IP público ainda pode confiar em requisições vindas de dentro da sua rede. Rejeite um hostname se qualquer um dos endereços resolvidos estiver bloqueado, não só o primeiro, porque o cliente pode tentá-los em qualquer ordem.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4 mapeado
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Valida todos os endereços resolvidos antes de o socket conectar
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

Um detalhe fácil de deixar passar: quando a URL contém um IP literal, o Node conecta diretamente sem chamar lookup nenhuma vez. Então a função de requisição precisa verificar os IPs literais por conta própria antes de repassar.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https nunca segue redirecionamentos; um 3xx é tratado como falha
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

Se você usa fetch no Node, que é construído sobre o undici, a mesma ideia se aplica por meio de um dispatcher customizado: crie um Agent do undici com a opção connect.lookup e passe-o como dispatcher. O princípio não muda: a verificação mora onde a conexão é feita. Se o seu ambiente roteia o tráfego de saída por um proxy HTTP, note que a resolução passa a acontecer no proxy para o host de destino, então o próprio proxy precisa aplicar as mesmas regras.

Desative redirecionamentos, ou revalide cada salto

Para webhooks, não siga redirecionamentos de jeito nenhum; um receptor que redireciona está mal configurado, e falhar de forma explícita avisa o cliente para corrigir o endpoint dele. Para prévias de link e importadores, onde redirecionamentos são normais, siga-os manualmente com um limite baixo e passe cada salto pelas mesmas verificações de esquema, porta e IP. Com o fetch, defina redirect: “manual” e trate o cabeçalho Location você mesmo.

Limite o que uma requisição bem-sucedida pode fazer

  • Defina timeouts de conexão e totais, e limite o tamanho da resposta, para que o buscador não possa ser usado para manter conexões abertas ou baixar arquivos enormes.
  • Não devolva respostas cruas nem mensagens de erro detalhadas ao usuário. Para prévias de link, devolva apenas o título, a descrição e a URL da imagem extraídos.
  • Remova seus próprios cabeçalhos de autenticação e cookies; o buscador nunca deve enviar credenciais internas a hosts fornecidos pelo usuário.

Roteie por um proxy de saída

A configuração mais robusta tira a política do código da aplicação. Rode as buscas de saída dirigidas pelo usuário através de um proxy de saída dedicado, ou a partir de um worker isolado num segmento de rede que simplesmente não tem rota até os serviços internos nem até o endpoint de metadados. Aí uma falha na validação da URL não vira uma violação, porque a própria rede recusa. Proxies de encaminhamento de código aberto conseguem aplicar regras de destino por você, e um worker separado também isola respostas lentas ou hostis do seu processo web principal.

Endureça o serviço de metadados

Na AWS, exija o IMDSv2, que precisa de um token de sessão obtido com uma requisição PUT, e mantenha o limite de saltos em 1 para que contêineres não o alcancem através do host. GCP e Azure exigem um cabeçalho específico nas chamadas de metadados. Isso eleva bastante a barra para um SSRF simples baseado em GET, mas são uma segunda camada, não um substituto para bloquear endereços link-local. Dê também ao papel da instância ou do serviço apenas as permissões de que a aplicação precisa, para que credenciais roubadas pelo serviço de metadados destravem o mínimo possível.

Testando suas defesas

  • Tente http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ e http://169.254.169.254/latest/meta-data/ e confirme que cada uma é rejeitada.
  • Aponte um hostname que você controle para 127.0.0.1 e confirme que a verificação na resolução o rejeita. Depois dê a ele dois registros A, um público e um privado, e confirme que continua sendo rejeitado.
  • Sirva um redirecionamento 302 para um endereço privado a partir de um host público e confirme que ele não é seguido.
  • Tente URLs file:, ftp: e gopher:, e URLs com credenciais ou portas incomuns.

Na revisão de código, procure todos os lugares em que uma URL de requisição é montada a partir de entrada: fetch, axios, got, requests, http.Get, chamadas page.goto de navegador headless e bibliotecas de processamento de imagem que aceitam URLs. O CodeAuditAgent rastreia esse fluxo de dados em repositórios públicos e trechos colados e reporta SSRF como CWE-918 com o ponto de chamada citado, um cenário de exploração e uma correção.