Proteja um endpoint de download contra SSRF
Construa um endpoint que baixa um arquivo de uma URL fornecida pelo usuário e o blinde contra Server-Side Request Forgery: só HTTPS, bloqueio de IP interno, revalidação de redirect, limites de tamanho/timeout/content-type e testes com mock de rede.
Faça fork ou clone (Ruby/Rails, Python/FastAPI ou TypeScript) e faça os testes que falham passarem.
Problema
Sua aplicação precisa de uma funcionalidade em que o usuário cola uma URL — digamos, uma imagem de avatar ou um arquivo de importação — e o servidor a baixa. Essa é uma das funcionalidades mais perigosas que você pode construir, porque a requisição parte **de dentro da sua infraestrutura**, com a posição de rede e a confiança do seu servidor. Um `fetch(file_url)` ingênuo é uma vulnerabilidade clássica de **Server-Side Request Forgery (SSRF)**. O atacante não precisa alcançar sua rede interna diretamente — ele apenas entrega ao seu servidor uma URL maliciosa e deixa que ele faça o alcance por ele. Concretamente, um atacante pode apontar `file_url` para: - **Endpoints de metadata da cloud** — `http://169.254.169.254/latest/meta-data/` na AWS/GCP/Azure. Numa instância com IMDSv1 sem correção, isso vaza credenciais IAM temporárias, o que é um comprometimento total da sua conta de cloud. - **Serviços internos** — `http://10.0.0.5:6379/` (Redis), `http://localhost:9200/` (Elasticsearch), painéis de admin, bancos de dados e endpoints de health/debug que só deveriam ser alcançáveis de dentro da VPC. - **Faixas link-local e loopback** — `127.0.0.1`, `169.254.0.0/16`, `[::1]` — para sondar serviços ligados à própria máquina. - **Esquemas não-HTTP** — `file:///etc/passwd`, `gopher://`, `ftp://` — para ler arquivos locais ou contrabandear bytes crus para dentro de outros protocolos. Neste lab você vai construir o endpoint de download **e depois atacá-lo**, para sentir por que cada defesa existe. Em seguida você empilha as defesas: só HTTPS, resolução DNS com bloqueio de faixas privadas, revalidação de redirect, limites rígidos de tamanho/tempo/content-type e um conjunto de testes que prova que cada bloqueio se mantém. > Este lab é concept-first e stack-agnóstico. Os exemplos usam pseudocódigo e > faixas de IP concretas; traduza-os para o cliente HTTP, o resolvedor DNS e as > ferramentas de teste/mock da sua linguagem.
Objetivos
Ao final deste lab você será capaz de:
- Explicar como o SSRF transforma uma funcionalidade de "baixar uma URL" em um
ataque contra sua própria rede interna e o metadata da cloud. - Rejeitar esquemas de URL perigosos e exigir busca só via HTTPS.
- Resolver um hostname via DNS e bloquear requisições que resolvem para faixas
de IP privado, loopback ou link-local (IPv4 e IPv6). - Seguir redirects com segurança revalidando o host de destino a cada salto.
- Impor limites rígidos: tamanho máximo da resposta (Content-Length e bytes
transmitidos), timeout de requisição e uma allowlist de content-type. - Escrever testes com mock de rede que provam que cada bloqueio (não-HTTPS, IP
privado, redirect malicioso, arquivo grande) realmente falha fechado.
Pré-requisitos
Para completar este lab você vai precisar de:
- Um projeto backend em qualquer linguagem/stack com um cliente HTTP, um
resolvedor DNS (ou uma forma de resolver um host para seus IPs) e um sistema
de jobs/workers em background. - Um framework de testes e uma forma de mockar ou stubar chamadas de rede
(ex.: um servidor HTTP de fixture local, uma biblioteca de interceptação de
requisições ou injeção de dependência do cliente HTTP). - Familiaridade básica com URLs, endereçamento IP e notação CIDR.
- A capacidade de rodar um job em background — a busca não pode rodar inline
na requisição web.
Você não precisa de um servidor vulnerável real para atacar; você vai
simular os alvos perigosos com endpoints locais/mockados.
O modelo mental: seu servidor é o "vice confuso"
SSRF é um problema de confused deputy (vice confuso). Seu servidor é um ator
confiável dentro do perímetro da rede. Quando ele busca uma URL em nome de um
usuário não confiável, ele empresta a esse usuário a sua confiança e a sua
posição de rede. A correção não é polvilhar uma blocklist de "domínios ruins" —
atacantes contornam blocklists de string trivialmente (encodings, truques de DNS,
0x7f.0.0.1, IPs em decimal). A correção é decidir, sobre o IP real resolvido,
se esse destino é permitido de todo.
Uma defesa robusta contra SSRF é um pequeno pipeline, e a ordem importa:
URL do usuário
│
▼ 1. parse + exigir esquema https://
▼ 2. resolver host → IPs (DNS)
▼ 3. rejeitar se QUALQUER IP resolvido for privado/loopback/link-local
▼ 4. conectar; a cada redirect, VOLTE ao passo 1 para a nova URL
▼ 5. impor timeout, tamanho máximo, allowlist de content-type
▼
bytes do arquivo (ou um erro genérico e seguro)
As faixas que você precisa bloquear
Estas são as faixas não roteáveis e internas. Se um hostname resolver para
qualquer uma delas, recuse a busca:
| Família | Faixa | Por quê |
|---|---|---|
| IPv4 | 127.0.0.0/8 |
Loopback (localhost, daemons internos) |
| IPv4 | 10.0.0.0/8 |
Rede privada |
| IPv4 | 172.16.0.0/12 |
Rede privada |
| IPv4 | 192.168.0.0/16 |
Rede privada |
| IPv4 | 169.254.0.0/16 |
Link-local — inclui o metadata da cloud 169.254.169.254 |
| IPv4 | 0.0.0.0/8 |
"Este host" / não especificado |
| IPv6 | ::1/128 |
Loopback |
| IPv6 | fc00::/7 |
Unique local (privado) |
| IPv6 | fe80::/10 |
Link-local |
Nota de segurança —
169.254.169.254: o serviço de metadata da cloud é o
alvo de SSRF de maior valor que existe. Na AWS/GCP/Azure ele responde nesse IP
link-local e, no IMDSv1 legado, entrega credenciais temporárias a quem pedir.
Bloquear a faixa inteira169.254.0.0/16(efe80::/10) é inegociável.
Falhe fechado e fique quieto
Duas regras transversais valem para todos os passos:
-
Rode em background. Faça a busca em um job/worker, nunca inline na
requisição web. Buscas inline permitem que um atacante use timing de
resposta e diferenças de erro como um oráculo para mapear sua rede interna,
e ainda travam suas threads web num DoS. -
Nunca ecoe o erro ou o corpo do upstream para o usuário. Retorne um
genérico "não foi possível buscar essa URL". Um connection-refused vs. timeout
vs. 200 vazado diz ao atacante exatamente o que está escutando internamente.
Logue os detalhes no servidor; não mostre nada útil a quem chamou.
Percorra os seis passos abaixo na ordem. Cada um se apoia no anterior.
Passos
-
Construa o endpoint ingênuo — e ataque-o
Comece construindo a versão vulnerável, para vê-la falhar. Crie um
endpoint que aceita umfile_urle o baixa:POST /downloads body: { "file_url": "https://example.com/report.pdf" } def create(file_url): response = http.get(file_url) # <-- ingênuo, segue qualquer coisa saved = storage.write(response.body) return { id: saved.id }Agora seja o atacante. Aponte
file_urlpara alvos que deveriam ser
proibidos e confirme que o endpoint ingênuo os alcança alegremente:# 1) Metadata da cloud — a joia da coroa file_url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" # 2) Serviço interno na máquina file_url = "http://127.0.0.1:6379/" # Redis # 3) Leitura de arquivo local via esquema não-HTTP file_url = "file:///etc/passwd"Observe que o handler ingênuo:
- segue qualquer esquema suportado pelo cliente HTTP,
- resolve e conecta a qualquer IP, inclusive internos,
- segue redirects cegamente,
- e pode ecoar o corpo ou o erro do upstream direto de volta para quem chamou.
Anote, para cada ataque acima, o que o endpoint retornou. Essa é sua evidência
do "antes". Os próximos cinco passos fecham cada buraco. Não faça deploy
desta versão — ela existe só para demonstrar o ataque num ambiente controlado. -
Exija HTTPS e valide a URL
O primeiro portão é a própria URL. Faça o parse com um parser de URL de
verdade (nunca com string matching / regex sobre a entrada crua) e imponha um
formato estrito.ESQUEMAS_PERMITIDOS = { "https" } def validar_url(raw): url = parse(raw) # parser real; rejeite em erro de parse if url.scheme not in ESQUEMAS_PERMITIDOS: # bloqueia http, file, gopher, ftp, data... rejeitar("esquema não permitido") if url.host vazio: rejeitar("host ausente") if url.userinfo presente: # ex.: https://user@evil@internal/ rejeitar("credenciais na URL não permitidas") return urlPor que cada regra:
-
Só HTTPS mata
file://(leitura de arquivo local),gopher://e
ftp://(smuggling de protocolo) e ohttp://em texto puro. Uma allowlist
de um único esquema é muito mais segura que uma blocklist que você precisa
manter completa. -
Rejeitar credenciais embutidas (
userinfo):
https://trusted.com@169.254.169.254/faz parse com host169.254.169.254
num parser correto, mas código descuidado que lê até o@se engana.
Rejeitaruserinforemove a ambiguidade por completo. -
Exigir host não vazio para que entradas relativas ou malformadas não
passem.
Só este passo já barra a variante
http://do ataque #1 e ofile://do
ataque #3 por completo. Mashttps://169.254.169.254/ainda é um HTTPS válido
apontando para o metadata — esse é o trabalho do próximo passo.Não tente validar o IP inspecionando a string do hostname aqui.
https://localhost/,https://0x7f000001/e um domínio cujo registro DNS
aponta para127.0.0.1são todos diferentes como string. A filtragem de IP
tem de acontecer sobre o endereço resolvido (Passo 3), não sobre o texto. -
Só HTTPS mata
-
Resolva o DNS e bloqueie IPs privados/loopback/link-local
Esta é a defesa central. Resolva o hostname para seus endereços IP reais e
recuse a busca se qualquer endereço resolvido cair numa faixa bloqueada.BLOQUEADAS_V4 = [ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "0.0.0.0/8", ] BLOQUEADAS_V6 = [ "::1/128", "fc00::/7", "fe80::/10" ] def garantir_host_publico(host): ips = dns_resolve(host) # pode retornar vários registros A/AAAA if ips vazio: rejeitar("host não resolve") for ip in ips: for cidr in BLOQUEADAS_V4 + BLOQUEADAS_V6: if ip in cidr: rejeitar("destino é um endereço privado/interno") return ipsDetalhes-chave que tornam isto correto:
-
Cheque todos os IPs resolvidos, não só o primeiro. Um hostname pode
retornar vários registros A/AAAA; o atacante pode listar um público e um
interno. Se qualquer um for interno, rejeite. -
Use uma checagem numérica de IP-em-CIDR, não comparação de string.
Faça o parse do IP para sua forma inteira/bytes e teste a contenção. Isso é
imune a0x7f.0.0.1,2130706433(decimal de127.0.0.1) e truques de
zero-padding — todos fazem parse para o mesmo endereço bloqueado. -
Cubra IPv6 e IPv6 mapeado de IPv4 (
::ffff:127.0.0.1). Normalize
endereços mapeados para a forma IPv4 antes de checar, ou bloqueie também a
faixa mapeada.
Agora
https://169.254.169.254/é recusado, assim como qualquer domínio que
o atacante registre e que resolva para127.0.0.1ou10.x.x.x.Avançado — DNS rebinding & TOCTOU. Há uma brecha entre resolver o host
e conectar: o DNS do atacante pode retornar um IP público para a checagem e,
microssegundos depois, um IP privado para a conexão real (uma corrida
time-of-check/time-of-use). A correção mais forte é resolver uma vez, escolher
um IP validado e conectar exatamente a esse IP enviando oHostheader
original — para que a conexão não possa ser reapontada. Anote isto como
meta de hardening; a revalidação por salto do Passo 4 também estreita a janela. -
Cheque todos os IPs resolvidos, não só o primeiro. Um hostname pode
-
Trate redirects com revalidação de host a cada salto
Uma única URL validada não basta:
https://evil.com/startpode retornar um
302 Location: http://169.254.169.254/. Se o seu cliente HTTP segue redirects
automaticamente, todo o trabalho dos Passos 2–3 é contornado no segundo salto.Desligue o seguimento automático de redirects e conduza você mesmo,
revalidando cada salto:MAX_REDIRECTS = 2 def buscar_protegido(url): for hop in 0..MAX_REDIRECTS: validar_url(url) # Passo 2: só https, sem userinfo ips = garantir_host_publico(url.host) # Passo 3: bloqueia faixas privadas resp = http.get(url, follow_redirects=false, connect_to=ips) if resp.is_redirect: url = resolver_relativo(url, resp.header["Location"]) continue # revalida na NOVA url return resp rejeitar("redirects demais")A regra crítica: cada salto passa de novo por toda a gauntlet dos Passos
2 + 3. Um alvo de redirect é apenas mais uma URL influenciada pelo usuário.
Limite a contagem de saltos (2 é de sobra para uso legítimo) para que um loop
de redirect não gire para sempre.Erros comuns que isto previne:
- Confiar no tratamento de redirect embutido do cliente (valida só o salto 0).
- Validar o host original mas conectar ao host redirecionado.
- Permitir saltos ilimitados (
Locationquicando como slow-loris / DoS).
Com redirects revalidados, o ataque "domínio público → 302 → IP de metadata"
agora falha no segundo salto exatamente como uma requisição direta falharia. -
Imponha limites de tamanho, timeout e content-type
Mesmo uma URL pública totalmente validada pode ser hostil: um arquivo enorme,
um stream lento ou um tipo inesperado. Adicione limites de recurso para que a
busca não possa virar um denial-of-service ou um vetor de confusão de tipo.MAX_BYTES = 100 * 1024 * 1024 # 100 MB TIMEOUT = 30_segundos # total, incluindo connect + read TIPOS_PERMITIDOS = { "application/pdf", "image/png", "image/jpeg" } def ler_limitado(resp): # 1) Não confie em nada: cheque o header E o stream real if resp.header["Content-Length"] and int(it) > MAX_BYTES: rejeitar("arquivo grande demais") ctype = resp.header["Content-Type"].split(";")[0].strip().lower() if ctype not in TIPOS_PERMITIDOS: rejeitar("content-type não permitido") # 2) Faça o stream e corte no limite — Content-Length pode mentir ou faltar total = 0 for chunk in resp.stream(TIMEOUT): total += len(chunk) if total > MAX_BYTES: abortar_conexao() rejeitar("arquivo grande demais") buffer.write(chunk) return bufferPor que cada limite:
-
Cheque o Content-Length e corte o stream. O header é uma dica que o
atacante controla — pode mentir, faltar ou subnotificar. Contar os bytes
enquanto você faz o stream e abortar ao passar do limite é a defesa de
verdade. Isso também neutraliza bombas de descompressão (um corpo gzip
pequeno que expande para gigabytes): limite os bytes descomprimidos. -
Timeout total. Um servidor que goteja um byte por segundo mantém seu
worker refém (slow-loris). Limite o tempo de connect + read. -
Allowlist de content-type. Se você pediu uma imagem de avatar, recuse
text/htmlouapplication/octet-stream. Isso estreita o que um upstream
comprometido pode alimentar aos parsers seguintes.
Combinados com a regra do job em background, esses limites impedem que uma
única URL maliciosa esgote memória, threads ou tempo. -
Cheque o Content-Length e corte o stream. O header é uma dica que o
-
Prove com testes de rede mockada (submissão)
Um controle de segurança que você não consegue testar vai apodrecer. Escreva
testes que mockem a rede e afirmem que cada defesa falha fechado. O mock
permite simular respostas perigosas (um redirect para o metadata, um corpo
gigante) sem levantar infraestrutura maliciosa real.Cubra, no mínimo, estes casos — cada um deve levantar uma rejeição / retornar
o erro genérico, nunca buscar o alvo proibido:test "rejeita esquema não-https": espera_rejeicao(buscar_protegido("http://example.com/x")) espera_rejeicao(buscar_protegido("file:///etc/passwd")) test "rejeita host que resolve para IP privado / link-local": stub_dns("evil.test" => ["169.254.169.254"]) # metadata da cloud espera_rejeicao(buscar_protegido("https://evil.test/")) stub_dns("lan.test" => ["10.0.0.5"]) espera_rejeicao(buscar_protegido("https://lan.test/")) test "rejeita redirect que aponta para IP interno": stub_http("https://ok.test/" => redirect_para("http://169.254.169.254/")) espera_rejeicao(buscar_protegido("https://ok.test/")) test "rejeita arquivo grande demais (header e stream)": stub_http("https://big.test/" => corpo_de(200 MB)) espera_rejeicao(buscar_protegido("https://big.test/")) test "rejeita content-type não permitido": stub_http("https://ok.test/" => resposta_html()) espera_rejeicao(buscar_protegido("https://ok.test/")) test "permite um arquivo https público bem formado": stub_dns("cdn.test" => ["93.184.216.34"]) # IP público stub_http("https://cdn.test/a.pdf" => resposta_pdf(1 MB)) espera_sucesso(buscar_protegido("https://cdn.test/a.pdf")) test "nunca vaza o erro do upstream para quem chamou": stub_http("https://ok.test/" => conexao_recusada()) resp = endpoint_download("https://ok.test/") espera(resp.body) == erro_generico() # sem oráculo de timing/erroAfirme também as regras transversais: a busca roda num job em background
(a requisição web retorna imediatamente) e o limite de saltos de redirect é
imposto.
Critério de submissão
Submeta quando tudo o que segue for verdade:
- Um endpoint no estilo
/downloadsque aceitafile_url, enfileira um
job em background e retorna uma resposta genérica (sem detalhe do
upstream). - A busca protegida impõe, nesta ordem: só HTTPS + validação de URL →
resolução DNS com bloqueio de privado/loopback/link-local → revalidação de
redirect por salto (limite ~2) → limites de tamanho + timeout + content-type. - Uma suíte de testes passando com rede mockada, provando cada bloqueio:
não-HTTPS, IP privado/link-local (incluindo169.254.169.254), redirect
malicioso, arquivo grande demais, content-type não permitido — mais uma
busca pública de happy-path e um teste de "o erro não é vazado".
Inclua uma nota curta sobre como você trataria DNS rebinding (conectar por
IP com umHostfixado) como passo futuro de hardening. - Um endpoint no estilo