Pular para o conteúdo

    Capítulo 21, Intermediário

    Upload de arquivos e arquivos estáticos

    Receber um arquivo é aceitar dados de um desconhecido e gravá-los no seu disco. Este é um dos pontos mais perigosos de uma API, e o exemplo clássico tem uma falha de segurança séria.

    O exemplo clássico e o que há de errado nele

    O código típico recebe um UploadFile, monta o caminho com file.filename e grava. Parece inofensivo, mas o nome do arquivo vem do cliente, e o cliente pode mandar ../fora.txt. Dá para ver o estrago em uma pasta de teste:

    intermediario/cap21_upload_estaticos.pylinhas 10 a 34
    import os
    import shutil
    from pathlib import Path
    
    from fastapi import FastAPI, File, HTTPException, UploadFile
    from fastapi.testclient import TestClient
    
    PASTA = Path("uploads")
    PASTA.mkdir(exist_ok=True)
    
    app_vulneravel = FastAPI()
    
    
    @app_vulneravel.post("/upload")
    def upload_vulneravel(file: UploadFile = File(...)):
        destino = os.path.join(PASTA, file.filename)
        with open(destino, "wb") as saida:
            shutil.copyfileobj(file.file, saida)
        return {"gravado_em": destino}
    
    
    cliente = TestClient(app_vulneravel)
    resposta = cliente.post("/upload", files={"file": ("../fora.txt", b"conteudo")})
    print(resposta.status_code, resposta.json())
    print("arquivo criado FORA da pasta de uploads:", Path("fora.txt").exists())
    
    Saída
    200 {'gravado_em': 'uploads/../fora.txt'}
    arquivo criado FORA da pasta de uploads: True
    

    O arquivo foi gravado fora da pasta de uploads. Com um nome como ../../app/main.py, um atacante sobrescreve o código da sua aplicação. Isso se chama path traversal.

    A forma segura

    A regra: nunca use o nome que o cliente mandou para decidir onde gravar. Eu gero o nome no servidor, valido a extensão contra uma lista permitida e limito o tamanho enquanto leio (sem carregar o arquivo inteiro na memória):

    intermediario/cap21_upload_estaticos.pylinhas 39 a 72
    import uuid
    
    EXTENSOES = {".png", ".jpg", ".jpeg", ".pdf", ".txt"}
    LIMITE_BYTES = 1_000_000
    BLOCO = 64 * 1024
    
    app = FastAPI()
    
    
    @app.post("/upload", status_code=201)
    async def upload(arquivo: UploadFile = File(...)):
        extensao = Path(arquivo.filename or "").suffix.lower()
        if extensao not in EXTENSOES:
            raise HTTPException(400, f"Extensão não permitida: {extensao or 'nenhuma'}")
    
        nome = f"{uuid.uuid4().hex}{extensao}"
        destino = PASTA / nome
        total = 0
        with open(destino, "wb") as saida:
            while bloco := await arquivo.read(BLOCO):
                total += len(bloco)
                if total > LIMITE_BYTES:
                    saida.close()
                    destino.unlink()
                    raise HTTPException(413, "Arquivo grande demais")
                saida.write(bloco)
        return {"nome": nome, "bytes": total}
    
    
    cliente = TestClient(app)
    ok = cliente.post("/upload", files={"arquivo": ("relatorio.txt", b"conteudo")})
    print(ok.status_code, ok.json()["bytes"], ok.json()["nome"].endswith(".txt"))
    print(cliente.post("/upload", files={"arquivo": ("virus.exe", b"x")}).status_code)
    print(cliente.post("/upload", files={"arquivo": ("grande.txt", b"x" * 2_000_000)}).status_code)
    
    Saída
    201 8 True
    400
    413
    

    O nome original (relatorio.txt) foi descartado: só a extensão (já validada) sobrevive, e o resto é um identificador aleatório. Agora o mesmo ataque não funciona:

    intermediario/cap21_upload_estaticos.pylinhas 74 a 77
    antes = {p.name for p in PASTA.iterdir()}
    ataque = cliente.post("/upload", files={"arquivo": ("../../fora2.txt", b"x")})
    novos = {p.name for p in PASTA.iterdir()} - antes
    print(ataque.status_code, len(novos), Path("fora2.txt").exists())
    
    Saída
    201 1 False
    

    O arquivo malicioso foi gravado dentro da pasta, com um nome gerado, e nada escapou.

    O que o cliente diz sobre o arquivo não é confiável

    O nome, a extensão e o content-type vêm do cliente, e ele pode mentir nos três. Para imagens, o mais seguro é conferir o conteúdo (por exemplo, os primeiros bytes de um PNG são sempre \x89PNG) ou, melhor, abrir o arquivo com uma biblioteca de imagens e recusar o que não abrir. Arquivos enviados por usuários também não devem ser executados nem servidos como HTML (um upload .html servido do seu domínio permite roubar sessões).

    Servir os arquivos

    Há duas formas. Para arquivos que qualquer um pode baixar, o StaticFiles monta uma pasta em um prefixo da URL. Para arquivos com controle de acesso, uma rota própria devolve um FileResponse depois de verificar a permissão.

    Existe uma armadilha no exemplo clássico: ele monta a pasta em /files e declara uma rota GET /files/{nome}. O mount captura todo o prefixo, e a rota nunca é alcançada. Dá para ver:

    intermediario/cap21_upload_estaticos.pylinhas 82 a 98
    from fastapi.staticfiles import StaticFiles
    
    (PASTA / "a.txt").write_text("conteúdo estático", encoding="utf-8")
    
    app_conflito = FastAPI()
    app_conflito.mount("/files", StaticFiles(directory=PASTA), name="files")
    
    
    @app_conflito.get("/files/{nome}")
    def rota_que_nunca_roda(nome: str):
        return {"rota": nome}
    
    
    c = TestClient(app_conflito)
    print(c.get("/files/a.txt").text)
    resposta = c.get("/files/inexistente.txt")
    print(resposta.status_code, resposta.text)
    
    Saída
    conteúdo estático
    404 {"detail":"Not Found"}
    

    Quem respondeu foi o StaticFiles, nas duas chamadas: a rota GET ficou escondida. A solução é prefixos diferentes, ou usar só um dos dois:

    intermediario/cap21_upload_estaticos.pylinhas 100 a 117
    from fastapi.responses import FileResponse
    
    app_servir = FastAPI()
    app_servir.mount("/publico", StaticFiles(directory=PASTA), name="publico")
    
    
    @app_servir.get("/arquivos/{nome}")
    def baixar(nome: str):
        caminho = (PASTA / nome).resolve()
        if PASTA.resolve() not in caminho.parents or not caminho.is_file():
            raise HTTPException(404, "Arquivo não encontrado")
        return FileResponse(caminho)
    
    
    s = TestClient(app_servir)
    print(s.get("/publico/a.txt").text)
    print(s.get("/arquivos/a.txt").text)
    print(s.get("/arquivos/inexistente.txt").status_code)
    
    Saída
    conteúdo estático
    conteúdo estático
    404
    

    A rota baixar faz uma checagem que o StaticFiles já faz por conta própria: ela resolve o caminho final e confere que ele está dentro da pasta, o que bloqueia .. na URL. Sem essa checagem, a rota de download teria o mesmo defeito do upload.

    Onde guardar os arquivos

    Gravar no disco do servidor funciona para aprender, mas em produção o disco costuma ser temporário (um deploy novo o apaga) e não é compartilhado entre instâncias. O padrão é guardar em um serviço de armazenamento de objetos (S3 e equivalentes) e salvar no banco só o nome e o endereço.

    Exercício 1

    Validar o nome de um arquivo

    Escreva extensao_segura(nome), que devolva a extensão em minúsculas se estiver em EXTENSOES, e levante ValueError caso contrário (inclusive para nomes sem extensão e para "relatorio.pdf.exe").