Skip to content

    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:

    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())
    
    Output
    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):

    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)
    
    Output
    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:

    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())
    
    Output
    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-type come 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 .html served 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:

    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)
    
    Output
    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:

    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)
    
    Output
    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").