Existe uma funcionalidade inofensiva em quase todo produto: buscar alguma coisa numa URL que o usuário informou.
Importar imagem de perfil por link. Validar um webhook que o cliente cadastrou. Gerar prévia de um site. Baixar um arquivo de uma integração.
Todas fazem a mesma coisa: a sua aplicação faz uma requisição pra um endereço escolhido por outra pessoa. E a sua aplicação está dentro da rede, onde os serviços internos costumam não exigir autenticação nenhuma porque “só a gente acessa”.
Isso é SSRF, e é a falha que transforma um recurso de conveniência num caminho pro seu ambiente interno.
O que dá pra alcançar
O alvo clássico é o serviço de metadados da nuvem, que responde num endereço fixo e entrega credencial temporária da instância. Um pedido de “gere a prévia dessa URL” apontando pra lá pode devolver chave de acesso.
Depois vêm os endereços internos: painel de administração de banco, cache sem senha, fila, serviço interno que confia em quem está na rede, e localhost da própria aplicação, que às vezes expõe rotas de depuração.
E file:///etc/passwd, se a biblioteca aceitar esquemas além de HTTP.
O detalhe que assusta: nada disso exige credencial. O atacante está usando a sua aplicação como procurador autenticado dentro da sua rede.
Por que a validação óbvia não funciona
A primeira tentativa é sempre bloquear endereços privados na string:
if (url.includes("127.0.0.1") || url.includes("localhost")) throw new Error();
As formas de contornar são muitas e conhecidas: notação decimal do endereço, IPv6, redirecionamento, domínio que resolve pra endereço interno, letras maiúsculas, credenciais na URL pra confundir o parser.
E a mais difícil de todas: o domínio resolve pra um endereço público na hora da validação e pra um endereço interno na hora da requisição. Isso tem nome, e a janela entre validar e conectar é o que torna qualquer validação por nome insuficiente.
O que funciona
Resolver o nome você mesmo, validar o IP, e conectar naquele IP. Isso fecha a janela entre validação e uso.
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const BLOCKED = [
/^127\./, /^10\./, /^192\.168\./, /^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./, /^0\./, /^::1$/, /^fe80:/i, /^fc00:/i,
];
async function resolveSafely(rawUrl: string) {
const url = new URL(rawUrl);
if (!["http:", "https:"].includes(url.protocol)) throw new BadRequestException();
const { address } = await lookup(url.hostname);
if (BLOCKED.some((re) => re.test(address))) throw new BadRequestException();
return { url, address };
}
E na hora de conectar, use o endereço resolvido com o cabeçalho Host correto, pra não resolver de novo.
Não seguir redirecionamento automaticamente. Ou, se precisar, validar cada salto com a mesma regra. Redirecionamento é o contorno mais usado, porque a primeira URL passa na validação e a segunda é a que interessa.
const res = await fetch(url, { redirect: "manual" });
Lista de permissão, quando possível. Se o produto só precisa buscar de domínios conhecidos, a lista de permissão resolve o problema inteiro e todas as tentativas de contorno morrem. Vale sempre perguntar se o requisito é mesmo “qualquer URL”.
A defesa que eu confio mais
Todas as anteriores são código, e código tem exceção esquecida.
A defesa estrutural é de rede: o serviço que faz requisições externas roda numa sub-rede sem rota pros serviços internos, saindo por um proxy de egresso que só permite destinos públicos.
Aí não importa quantos contornos de parser existam. O pacote não tem por onde chegar.
Some a isso desabilitar o acesso ao serviço de metadados da nuvem a partir dos contêineres que não precisam dele, ou exigir a versão do protocolo que requer token, que quebra a exploração ingênua.
Onde procurar no seu sistema
Uma varredura curta que costuma render:
grep -rn "fetch(\|axios.get(\|httpClient" src/ | grep -i "req\.\|dto\.\|input\|url"
Os candidatos típicos: importação de imagem por URL, cadastro de webhook, prévia de link, importação de feed, integração configurável pelo cliente e conversão de documento por URL.
Vale incluir na lista o que gera PDF a partir de HTML: se o HTML vem do usuário e o renderizador busca recursos, você tem SSRF pelo caminho indireto.
O teste
Cadastre um webhook apontando pro endereço do serviço de metadados da nuvem e veja o que volta.
Se voltar JSON com credencial, você tem um achado grave. Se der erro de conexão, teste também um domínio público que você controla e que redireciona pra um endereço interno, porque esse é o caso que a validação ingênua deixa passar.
Dez minutos, e é o tipo de teste que raramente é feito porque a funcionalidade parece inofensiva demais pra ser perigosa.
Sobre a família de problemas em que isso se encaixa, escrevi em como construir aplicações seguras.