Chapter 21, Intermediate
File uploads and static files
Receiving a file means accepting data from an unknown source and writing it to your disk. This is one of the most dangerous points of an API, and the classic example has a serious security flaw.
The classic example and what is wrong with it
The typical code receives a UploadFile, builds the path with file.filename and writes it. It seems harmless, but the file name comes from the client, and the client can send ../fora.txt. You can see the damage in a test folder:
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
The file was written outside the uploads folder. With a name like ../../app/main.py, an attacker overwrites your application's code. This is called path traversal.
The secure way
The rule: never use the name that the client sent to decide where to write. I generate the name on the server, validate the extension against an allowed list, and limit the size while reading (without loading the entire file into memory):
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
The original name (relatorio.txt) was discarded: only the extension (already validated) survives, and the rest is a random identifier. Now the same attack does not work:
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
The malicious file was written inside outside the folder, with a generated name, and nothing escaped.
What the client says about the file is not trustworthy
The name, the extension, and the
content-typecome from the client, and they can lie about all three. For images, the safest approach is to check the content (for example, the first bytes of a PNG are always\x89PNG) or, better, open the file with an image library and reject what does not open. Files uploaded by users should also not be executed or served as HTML (an upload.htmlserved from your domain allows session stealing).
Serving the files
There are two ways. For files that anyone can download, the StaticFiles mounts a folder at a URL prefix. For files with access control, a dedicated route returns a FileResponse after checking the permission.
There is a trap in the classic example: it mounts the folder at /files and declares a route GET /files/{nome}. The mount captures the entire prefix, and the route is never reached. You can see:
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"}
Who responded was the StaticFiles, in both calls: the route GET was hidden. The solution is different prefixes, or use only one of the two:
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
The route baixar performs a check that the StaticFiles already does on its own: it resolves the final path and checks that it is within the folder, which blocks .. in the URL. Without this check, the download route would have the same flaw as the upload.
Where to store the files
Writing to the server's disk works for learning, but in production the disk is usually temporary (a new deploy erases it) and not shared between instances. The standard is to store in an object storage service (S3 and equivalents) and save only the name and address in the database.
Exercise 1
Validate the name of a file
Write extensao_segura(nome), which returns the extension in lowercase if it is in EXTENSOES, and raises ValueError otherwise (including for names without an extension and for "relatorio.pdf.exe").