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:
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())
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):
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)
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:
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())
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-typevê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.htmlservido 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:
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)
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:
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)
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").