Security

Construa um Serviço de Importação de Arquivos Seguro

Aplique DARE de ponta a ponta no seu próprio repo para construir um serviço que importa arquivos de URLs fornecidas pelo usuário — blindado contra SSRF, com validação de conteúdo, processamento assíncrono em jobs e trilha de auditoria. Puxa o lab de SSRF e o playbook de jobs.

O que você vai construir

"Importar um arquivo por um link" é uma das funcionalidades mais comuns — e
mais perigosas — da web. O usuário cola uma URL (um CSV para importar, uma
imagem de avatar, um logo) e o seu servidor a busca. No instante em que o seu
servidor faz essa requisição, ele a faz de dentro da sua infraestrutura,
carregando a sua posição de rede e a sua confiança. Um fetch(file_url)
ingênuo é um Server-Side Request Forgery (SSRF) de manual: o atacante nunca
toca a sua rede interna diretamente — ele entrega uma URL ao seu servidor e
deixa que ele faça o alcance por ele.

Neste capstone você constrói um serviço de importação de arquivos real, de
ponta a ponta, no seu próprio repositório, aplicando o método DARE completo:
POST /imports { file_url } aceita um link, uma camada de safe-fetch recusa
tudo que é perigoso, um job em background baixa e valida o conteúdo de forma
assíncrona, o resultado é persistido no storage e todo evento relevante é
escrito numa trilha de auditoria. Isto não é um starter que você clona — é
um guia que você aplica. A stack, a linguagem e o framework são seus; o DARE dá
o formato.

O que você entrega

Como o DARE guia você

Você vai percorrer as fases do método, cada uma como um milestone:

Design    → o problema + modelo de ameaça (SSRF, arquivos maliciosos, exaustão)
Blueprint → o contrato do endpoint, as regras do validador SSRF, síncrono-vs-job,
            o schema de auditoria, o storage
Tasks     → decompor num DAG com dependências mínimas
Execute   → (a) o fetch seguro contra SSRF, depois (b) o import assíncrono + auditoria
Harden    → testes que provam cada bloqueio, mais o critério de submissão

Dois recursos companheiros alimentam este projeto diretamente. O lab "Proteja
um endpoint de download contra SSRF"
é o exercício concept-first do pipeline
de safe-fetch que você vai construir no milestone 4. O playbook "Rode e
diagnostique jobs em background"
é a sua referência para o worker, os retries
e a observabilidade de que você vai precisar no milestone 5. Mantenha os dois
abertos enquanto avança.

Isto é um guia, não código. Todo trecho é pseudocódigo ou um esboço de schema
— traduza para o seu cliente HTTP, resolvedor DNS, sistema de jobs e
ferramentas de teste. O objetivo é que o seu repo termine o projeto com um
serviço de importação funcional, testado, auditado e seguro contra SSRF.

Arquitetura

Arquitetura de referência

O serviço é um endpoint de escrita fino na frente de um pipeline assíncrono. A
requisição web faz quase nada: valida o formato da entrada, cria um registro
import num estado pending, enfileira um job e retorna um id. Todo o trabalho
arriscado — DNS, conectar, baixar, validar conteúdo — acontece no worker, fora
da thread da requisição.

flowchart TD
    U[Cliente] -->|POST /imports file_url| API[Endpoint de import]
    API -->|checa formato + cria registro| DB[(tabela imports)]
    API -->|enfileira| Q[[Fila de jobs]]
    API -->|202 + id do import| U
    Q --> W[Worker de import]
    W --> SF{Guarda safe-fetch}
    SF -->|1 só https + validação de URL| SF
    SF -->|2 resolve DNS + bloqueia privado/loopback/link-local| SF
    SF -->|3 revalida redirect por salto max 2| SF
    SF -->|4 limites de tamanho + timeout + content-type| SF
    SF -->|rejeita| AUD[(auditoria import_events)]
    SF -->|bytes ok| CV[Validador de conteúdo]
    CV -->|inválido| AUD
    CV -->|válido| ST[(Object storage)]
    ST --> DB
    W -->|cada transição| AUD
    BLK[[metadata 169.254.169.254 / 10.x / 127.x / fe80::]]:::danger -.->|bloqueado no passo 2| SF
    classDef danger fill:#3b0d0d,stroke:#b71c1c,color:#fff;

Componentes

Esboço do modelo de dados

imports
  id, user_id, file_url, status(pending|processing|succeeded|failed),
  content_type, byte_size, storage_key, attempts, created_at, updated_at

import_events
  id, import_id (fk), kind, detail(jsonb), created_at
  # kind ∈ {queued, fetch_started, fetch_rejected, content_rejected,
  #         stored, failed, retried}
  # detail guarda o motivo no servidor (IP bloqueado, tamanho, tipo) — nunca retornado ao cliente

Trade-offs para decidir no Blueprint

Marcos

  1. Design — o problema e o modelo de ameaça

    Antes de uma linha de código, escreva o Design: o que o serviço faz, quem
    o usa e — o mais importante — como ele pode ser abusado. Importar um arquivo
    de uma URL fornecida pelo usuário é uma requisição que o seu servidor faz em
    nome do usuário, de dentro do seu perímetro
    , então o modelo de ameaça é o
    coração deste projeto.

    Produza um documento de design curto que responda:

    • A funcionalidade. Um parágrafo: usuários submetem uma URL, o serviço
      importa o arquivo (CSV/imagem) de forma assíncrona e guarda o resultado;
      usuários podem checar o status. Nomeie os casos de uso concretos que você
      vai suportar.
    • Os atores. Quem chama POST /imports (usuários autenticados), quem roda
      os workers (sua infra) e quem é o adversário (qualquer usuário que
      consiga pôr uma string em file_url).
    • O modelo de ameaça. Enumere os casos de abuso explicitamente:
      • SSRF — a URL aponta para alvos internos: metadata da cloud
        (169.254.169.254), serviços de loopback (127.0.0.1:6379 Redis),
        faixas privadas (10.x, 192.168.x) ou esquemas não-HTTP (file://,
        gopher://).
      • Arquivos maliciosos — uma URL pública válida que retorna uma bomba de
        descompressão, um content-type errado/mal rotulado ou um arquivo forjado
        para quebrar o parser seguinte.
      • Exaustão de recursos — um arquivo enorme, um gotejar lento
        (slow-loris) ou uma enxurrada de requisições de import, qualquer um deles
        esgotando memória, threads ou tempo.
      • Vazamento de informação — ecoar erros/timing do upstream de volta a
        quem chamou, transformando o endpoint num oráculo que mapeia sua rede.
    • Escopo. O que está dentro (fetch seguro, import assíncrono, validação de
      conteúdo, auditoria) e explicitamente fora (ex.: varredura de vírus, UI de
      retry para o usuário) neste capstone.

    Pronto quando: um documento DESIGN existe no seu repo nomeando a
    funcionalidade, os atores e as quatro classes de ameaça acima (SSRF, arquivos
    maliciosos, exaustão, vazamento), cada uma com pelo menos um exemplo concreto
    de ataque, mais uma lista explícita de escopo dentro/fora. Se você não
    consegue nomear o ataque, não consegue se defender dele — este milestone é a
    fonte da verdade de todo teste posterior.

  2. Blueprint — contrato do endpoint, regras SSRF, storage e schema de auditoria

    Transforme o Design num blueprint de arquitetura: os contratos, regras e
    schema concretos contra os quais você vai construir. É aqui que você toma as
    decisões para que o Execute seja mecânico.

    Especifique cada um destes:

    • Contrato do endpoint.
      POST /imports
      body: { "file_url": "https://cdn.example.com/data.csv" }
      202 Accepted → { "id": "imp_123", "status": "pending" }
      
      GET /imports/:id
      200 OK → { "id": "imp_123", "status": "succeeded|processing|failed" }
      
      A escrita retorna imediatamente; o status é consultado. O corpo não
      carrega detalhe do upstream em falha — só um status genérico.
    • Regras do validador SSRF (enumeradas). Escreva-as como uma lista
      ordenada e testável, porque a ordem é uma propriedade de segurança:
      1. Parse com um parser de URL real; exija scheme == https; rejeite
        userinfo; exija host não vazio.
      2. Resolva o host para IPs; rejeite se qualquer IP estiver numa faixa
        bloqueada (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 incl. 169.254.169.254,
        0.0.0.0/8, ::1/128, fc00::/7, fe80::/10); use IP-em-CIDR
        numérico, cubra IPv6 mapeado de IPv4.
      3. Desligue auto-redirects; a cada salto rode de novo as regras 1–2 na nova
        URL; limite a ~2 saltos.
      4. Imponha timeout total, máximo de bytes (Content-Length e stream),
        allowlist de content-type.
    • Decisão síncrono vs. job. Declare a regra e o porquê: a busca roda num
      job em background, nunca inline — porque buscas inline vazam um oráculo
      de timing/erro e expõem as threads web a DoS. Defina a fila, o nome do job e
      o que o endpoint faz de forma síncrona (checagem de formato + enfileirar).
    • Schema de auditoria. Defina as duas tabelas da arquitetura: imports
      (agregado + status atual) e import_events (linha do tempo append-only com
      kind e um detail só de servidor). Liste os tipos de evento exatos.
    • Storage. Decida onde os bytes validados moram (object storage indexado
      pelo id do import) e que o banco guarda um ponteiro + metadados, não o blob.

    Pronto quando: um documento BLUEPRINT fixa os formatos de
    request/response, as quatro regras SSRF ordenadas como lista numerada, a
    decisão explícita de síncrono-vs-job com justificativa, o schema imports +
    import_events com tipos de evento nomeados, e a escolha de storage. Toda
    task posterior deve conseguir citar uma linha deste blueprint.

  3. Tasks — decompor num DAG com dependências mínimas

    Quebre o blueprint em tasks atômicas e testáveis de forma independente e
    conecte suas dependências num DAG. A meta é um grafo onde tudo que pode ser
    construído em paralelo é, e cada aresta é uma relação real de "precisa da
    saída de" — não um acidente de como você por acaso pensou no assunto.

    Decomposição sugerida (adapte à sua stack):

    T1  Validador de URL      (scheme=https, sem userinfo, host não vazio)
    T2  Guarda de DNS + IP    (resolve host, bloqueia privado/loopback/link-local)
    T3  Fetch protegido       (T1 + T2 + sem auto-redirect, revalida por salto, limites)
    T4  Endpoint de import    (checa formato, cria registro, enfileira) — precisa do schema
    T5  Schema + writer de auditoria (tabelas imports + import_events, escritor de evento)
    T6  Worker de import      (roda T3, valida conteúdo, guarda, audita via T5)
    T7  Retry + idempotência  (re-execução segura de T6 em falha)
    T8  Suíte de testes       (prova que cada bloqueio de T1–T7 falha fechado)
    

    Depois desenhe as arestas de dependência e mantenha-as mínimas:

    flowchart LR
        T1[Validador de URL] --> T3[Fetch protegido]
        T2[Guarda DNS + IP] --> T3
        T5[Schema + writer de auditoria] --> T4[Endpoint de import]
        T5 --> T6[Worker de import]
        T3 --> T6
        T4 --> T6
        T6 --> T7[Retry + idempotência]
        T7 --> T8[Suíte de testes]
        T3 --> T8
    

    Note o paralelismo que o grafo expõe: T1, T2 e T5 não têm dependências e
    podem ser construídas ao mesmo tempo. Mantenha as arestas honestas — se uma
    task não precisa de verdade da saída de outra, não adicione a aresta, ou você
    vai serializar trabalho sem motivo.

    Pronto quando: você tem uma lista de tasks em que cada task é pequena o
    bastante para construir e testar por conta própria, um DAG (dare-dag.yaml
    ou equivalente) que valida sem ciclos e sem referências quebradas, e o grafo
    deixa as tasks independentes (validador, guarda de IP, auditoria)
    visivelmente paralelas. Cada task deve nomear sua checagem de "pronto".

  4. Execute: validação SSRF — construa o safe-fetch

    Implemente o safe-fetch — o firewall de SSRF das tasks T1–T3. Este é o
    núcleo de segurança do projeto; construa-o isolado e teste-o com rigor antes
    de conectá-lo ao worker. O lab de SSRF companheiro percorre os conceitos;
    aqui você o torna real no seu repo.

    Construa o pipeline em ordem estrita — a ordem é a propriedade de segurança:

    MAX_REDIRECTS = 2
    BLOQUEADAS = [ "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","::1/128","fc00::/7","fe80::/10" ]
    
    def safe_fetch(url_crua):
        url = url_crua
        for hop in 0..MAX_REDIRECTS:
            u = parse(url)                          # parser real, não regex
            if u.scheme != "https": rejeitar        # mata http/file/gopher/ftp/data
            if u.userinfo presente: rejeitar        # https://ok@169.254.169.254/
            if u.host vazio: rejeitar
            ips = dns_resolve(u.host)               # pode ser vários A/AAAA
            if ips vazio: rejeitar
            for ip in ips:                          # cheque TODOS os IPs resolvidos
                if ip_em_cidr_numerico(ip, BLOQUEADAS): rejeitar   # incl. 169.254.169.254
            resp = http.get(u, follow_redirects=false, connect_to=ips, timeout=30s)
            if resp.is_redirect:
                url = resolver_relativo(u, resp.header["Location"])
                continue                            # revalida a NOVA url
            return ler_limitado(resp)               # limites de tamanho + content-type
        rejeitar("redirects demais")
    

    Detalhes inegociáveis:

    • Só HTTPS, parser real, rejeite userinfo — uma allowlist de um esquema
      vence uma denylist que você precisa manter completa; userinfo remove a
      ambiguidade trusted.com@internal.
    • Bloqueie sobre o IP resolvido, numericamente — faça o parse de cada IP
      para bytes e teste a contenção CIDR. Imune a 0x7f.0.0.1, decimal
      2130706433 e zero-padding. Cubra IPv6 mapeado de IPv4
      (::ffff:127.0.0.1). Rejeite se qualquer IP resolvido for interno. O
      bloqueio de 169.254.0.0/16 (metadata da cloud) é obrigatório.
    • Conduza os redirects você mesmo, revalide cada salto — desligue o
      auto-follow; um 302 → http://169.254.169.254/ precisa passar pela
      gauntlet inteira de novo. Limite os saltos a ~2.
    • Limites — timeout total; máximo de bytes checado no header e contando
      os bytes do stream (Content-Length pode mentir); allowlist de content-type;
      limite o tamanho descomprimido para neutralizar bombas de descompressão.
    • Falhe fechado e quieto — toda rejeição retorna um erro genérico; o
      motivo é logado só no servidor.

    Pronto quando: safe_fetch existe como um módulo isolado e testado em
    unidade que, com a rede mockada, rejeita: esquemas não-HTTPS, um host que
    resolve para 169.254.169.254 / 10.0.0.5 / 127.0.0.1, um redirect para um
    IP interno, um corpo grande demais (header e stream) e um content-type não
    permitido — e permite um arquivo HTTPS público bem formado. Ele nunca ecoa
    detalhe do upstream.

  5. Execute: import assíncrono — job, validação de conteúdo, persistência e auditoria

    Conecte o safe-fetch ao pipeline de import assíncrono (tasks T4–T7). O
    endpoint enfileira; o worker faz o trabalho arriscado; cada passo é auditado.
    O playbook companheiro "Rode e diagnostique jobs em background" é a sua
    referência para o worker, os retries e a observabilidade.

    Construa em duas metades:

    1. O endpoint (síncrono, minúsculo).

    POST /imports (file_url):
        validar_formato(file_url)                   # só presente, string, limite de tamanho
        imp = imports.create(user_id, file_url, status: "pending")
        audit(imp, kind: "queued")
        ImportJob.enqueue(imp.id)                    # retorna antes de qualquer fetch
        return 202, { id: imp.id, status: "pending" }
    

    2. O worker (assíncrono, protegido, auditado).

    ImportJob(import_id):
        imp = imports.find(import_id)
        imp.update(status: "processing"); audit(imp, "fetch_started")
        try:
            bytes = safe_fetch(imp.file_url)         # milestone 4; pode rejeitar
        except Rejeitado as e:
            imp.update(status: "failed"); audit(imp, "fetch_rejected", detail: e.motivo)
            return                                    # NÃO faça retry de rejeição de política
        if not conteudo_valido(bytes, tipo_esperado): # sniff de magic-byte, parse CSV, limite de linhas
            imp.update(status: "failed"); audit(imp, "content_rejected", detail: motivo)
            return
        key = storage.put(import_id, bytes)
        imp.update(status: "succeeded", storage_key: key,
                   content_type: farejado, byte_size: len(bytes))
        audit(imp, "stored")
    

    Decisões-chave para acertar:

    • Validação de conteúdo é separada dos limites de transporte. O safe-fetch
      limita tamanho/tipo no fio; aqui você confirma que os bytes são o que você
      pediu
      — fareje magic bytes (não confie no header), parseie o CSV e limite
      linhas/colunas, rejeite em falha de parse.
    • Falha vs. retry. Distinga uma rejeição de política (bloqueio SSRF,
      conteúdo ruim — determinística, vai falhar de novo → não faça retry,
      marque failed) de um erro transitório (oscilação de DNS, 503 do
      upstream, timeout → retry com backoff). Só faça retry da classe transitória.
    • Retries idempotentes. Um job re-executado não pode guardar em dobro nem
      auditar em dobro. Indexe o storage por import_id, faça a transição para
      "succeeded" ser uma mudança de estado protegida e registre um evento
      retried para a linha do tempo continuar verdadeira.
    • Audite cada transição. queued → fetch_started → (fetch_rejected | content_rejected | stored | failed | retried). O detail guarda o motivo
      só de servidor; a API ainda retorna só um status genérico.

    Pronto quando: POST /imports enfileira e retorna 202 sem buscar; o
    worker roda o safe-fetch, valida o conteúdo, guarda o resultado e escreve um
    evento de auditoria para cada transição; uma rejeição de política marca
    failed sem retry enquanto um erro transitório faz retry com backoff; e um
    job re-executado é idempotente (sem linhas de storage ou auditoria
    duplicadas).

  6. Harden & verify — testes e critério de submissão

    Um controle de segurança que você não consegue testar vai apodrecer. Feche o
    projeto provando, com a rede mockada, que cada defesa falha fechado e que
    o caminho feliz funciona — e então atinja o critério de submissão.

    Cubra, no mínimo, estes casos (o mock permite simular respostas perigosas sem
    infraestrutura maliciosa real):

    test "rejeita esquema não-https":
        espera_rejeicao(safe_fetch("http://example.com/x"))
        espera_rejeicao(safe_fetch("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(safe_fetch("https://evil.test/"))
        stub_dns("lan.test"  => ["10.0.0.5"])
        espera_rejeicao(safe_fetch("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(safe_fetch("https://ok.test/"))
    
    test "rejeita arquivo grande demais (header e stream)":
        stub_http("https://big.test/" => corpo_de(500 MB))
        espera_rejeicao(safe_fetch("https://big.test/"))
    
    test "o job de import processa um arquivo válido e o audita":
        stub_dns("cdn.test" => ["93.184.216.34"])          # IP público
        stub_http("https://cdn.test/a.csv" => resposta_csv(1 MB))
        imp = rodar_import("https://cdn.test/a.csv")
        espera(imp.status) == "succeeded"
        espera(tipos_de_auditoria(imp)) inclui ["queued","fetch_started","stored"]
    
    test "um retry é idempotente (sem storage ou auditoria duplicados)":
        first = rodar_import_falhando_uma_vez("https://cdn.test/a.csv")  # erro transitório, depois ok
        espera(imp.status) == "succeeded"
        espera(objetos_storage(imp).count) == 1
        espera(tipos_de_auditoria(imp)) inclui ["retried"]
    
    test "nunca vaza o erro do upstream para quem chamou":
        stub_http("https://ok.test/" => conexao_recusada())
        resp = post_imports("https://ok.test/")
        espera(resp.body) == status_generico()             # sem oráculo de timing/erro
    

    Afirme também as regras transversais: POST /imports retorna antes de
    qualquer busca (o trabalho está num job) e o limite de saltos de redirect é
    imposto.


    Critério de submissão

    Submeta quando tudo o que segue for verdade no seu repo:

    1. Endpoint — POST /imports { file_url } valida só o formato, cria um
      import pending, enfileira um job em background e retorna 202 com um
      id; GET /imports/:id reporta o status sem detalhe do upstream.
    2. Safe-fetch — impõe, nesta ordem: só HTTPS + validação de URL →
      resolução DNS bloqueando privado/loopback/link-local (incluindo
      169.254.169.254) → revalidação de redirect por salto (limite ~2) →
      limites de tamanho + timeout + content-type.
    3. Import assíncrono + auditoria — o worker roda o safe-fetch, valida o
      conteúdo, guarda o resultado e escreve uma linha import_events para cada
      transição; rejeições de política marcam failed sem retry, erros
      transitórios fazem retry com backoff e os retries são idempotentes.
    4. Testes (rede mockada) provando cada bloqueio: não-HTTPS, IP
      privado/link-local (incl. metadata), redirect malicioso, arquivo grande
      demais — mais um import de happy-path que audita, um retry idempotente e um
      teste de "o erro não é vazado".

    Inclua uma nota curta sobre hardening de DNS rebinding (conectar por IP
    com um Host fixado) como passo futuro, e confirme que nenhum erro, corpo ou
    timing do upstream é jamais retornado a quem chamou.