Security · 75 min

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.

Baixar o projeto inicial

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:

Pré-requisitos

Para completar este lab você vai precisar de:

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 inteira 169.254.0.0/16 (e fe80::/10) é inegociável.

Falhe fechado e fique quieto

Duas regras transversais valem para todos os passos:

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

  1. Construa o endpoint ingênuo — e ataque-o

    Comece construindo a versão vulnerável, para vê-la falhar. Crie um
    endpoint que aceita um file_url e 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_url para 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.

  2. 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 url
    

    Por que cada regra:

    • Só HTTPS mata file:// (leitura de arquivo local), gopher:// e
      ftp:// (smuggling de protocolo) e o http:// 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 host 169.254.169.254
      num parser correto, mas código descuidado que lê até o @ se engana.
      Rejeitar userinfo remove 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 o file:// do
    ataque #3 por completo. Mas https://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 para 127.0.0.1 são todos diferentes como string. A filtragem de IP
    tem de acontecer sobre o endereço resolvido (Passo 3), não sobre o texto.

  3. 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 ips
    

    Detalhes-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 a 0x7f.0.0.1, 2130706433 (decimal de 127.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 para 127.0.0.1 ou 10.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 o Host header
    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.

  4. Trate redirects com revalidação de host a cada salto

    Uma única URL validada não basta: https://evil.com/start pode 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 (Location quicando 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.

  5. 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 buffer
    

    Por 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/html ou application/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.

  6. 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/erro
    

    Afirme 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:

    1. Um endpoint no estilo /downloads que aceita file_url, enfileira um
      job em background e retorna uma resposta genérica (sem detalhe do
      upstream).
    2. 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.
    3. Uma suíte de testes passando com rede mockada, provando cada bloqueio:
      não-HTTPS, IP privado/link-local (incluindo 169.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 um Host fixado) como passo futuro de hardening.