Approfondimenti e Trend sull'Intelligenza Artificiale

Allowlist upload file con Flask/FastAPI (anti-blocklist)

11 min di lettura
Gioco a incastro per bambini con fori a forma di quadrato, stella, cerchio, triangolo e fiore, circondato da blocchi colorati su fondo giallo
Windows macOS Linux
Foto: Towfiqu Barbhuiya · Pexels · Licenza Pexels

Perché vietare .exe e .php non basta e come accettare solo i file che vuoi in Flask e FastAPI: allowlist, firma dei byte, limite di peso e nome casuale.

Hai chiesto all’IA un modulo per caricare le foto dei documenti o i PDF delle fatture, e il codice ha un controllo che rifiuta .exe, .sh e .php. Sembra prudente. Nell’analisi di 100 programmi scritti dall’IA e cosa ho scoperto è uno degli errori ricorrenti: una blocklist, cioè un elenco di cose vietate. Qui la metti alla prova e la sostituisci con un’allowlist in Flask e in FastAPI. Vietare le estensioni pericolose non funziona, perché basta un nome che nessuno ha pensato di vietare; accetta solo le estensioni che ti servono e controlla che il contenuto corrisponda.

La risposta breve: tieni un elenco delle estensioni ammesse (per esempio .jpg, .png, .pdf), confrontalo con l’estensione in minuscolo, controlla i primi byte del file, metti un limite di peso, salva con un nome generato da te in una cartella che il sito non serve direttamente. Nella prova, la blocklist ha accettato 6 file su 7, compreso uno salvato fuori dalla sua cartella; le due versioni con allowlist hanno accettato solo i due file buoni, rinominati.

ℹ️ Nota / Prerequisito operativo: ti serve Python 3.11 o successivo (le versioni dei pacchetti provate qui lo richiedono) e un ambiente virtuale. Su Windows usa PowerShell e il comando python; su macOS e Linux il Terminale e python3. Controlla con python --version (Windows) o python3 --version (macOS/Linux).

Installare i pacchetti

Windows (PowerShell):

python -m pip install flask fastapi python-multipart uvicorn httpx
python -m pip show flask fastapi

macOS / Linux:

python3 -m pip install flask fastapi python-multipart uvicorn httpx
python3 -m pip show flask fastapi

pip show stampa nome e versione di ogni pacchetto (Name: Flask, Version: 3.1.3 nella prova). python-multipart serve a FastAPI per leggere i file inviati da un modulo; senza, all’avvio compare un errore che chiede di installarlo. uvicorn avvia l’app FastAPI, httpx serve solo per il test automatico.

Il codice da non usare: la blocklist

Questa è la versione tipica che l’IA propone quando chiedi «blocca i file pericolosi»:

# Versione da NON usare: blocklist di estensioni
from pathlib import Path

from flask import Flask, request

app = Flask(__name__)
CARTELLA = Path("caricati")
CARTELLA.mkdir(exist_ok=True)
VIETATE = {".exe", ".sh", ".php"}


@app.post("/carica")
def carica():
    file = request.files["file"]
    if Path(file.filename).suffix in VIETATE:
        return {"errore": "estensione vietata"}, 400
    file.save(CARTELLA / file.filename)
    return {"salvato": file.filename}, 201

Ha tre buchi. Il controllo distingue maiuscole e minuscole, quindi script.PHP passa. L’elenco è incompleto: .phtml e altre varianti che alcuni server eseguono come PHP non ci sono. E il nome scelto dall’utente finisce sul disco così com’è, quindi ../fuori.jpg scrive un livello sopra la cartella prevista. In più non c’è limite di peso e non c’è nessun controllo sul contenuto: un testo PHP chiamato finta-foto.jpg viene accettato.

L’allowlist in Flask

La versione corretta ribalta la logica: invece di elencare cosa vietare, elenca le poche estensioni che ti servono e per ognuna la firma che il file deve avere nei primi byte. Tutto il resto viene rifiutato.

Porta in legno verde a doghe con una fessura per la posta orizzontale, accanto a un muro intonacato azzurro
Una fessura fatta per le lettere: passa solo quello che ha la forma prevista. Foto: Jan van der Wolf · Pexels
# Upload con allowlist: Flask
import secrets
from pathlib import Path

from flask import Flask, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024  # 5 MB per richiesta

CARTELLA = Path("caricati").resolve()  # fuori da static/ e templates/
CARTELLA.mkdir(exist_ok=True)

# estensione ammessa -> firme possibili dei primi byte del file
AMMESSI = {
    ".jpg": (b"\xff\xd8\xff",),
    ".jpeg": (b"\xff\xd8\xff",),
    ".png": (b"\x89PNG\r\n\x1a\n",),
    ".pdf": (b"%PDF-",),
}


def controlla(nome, inizio):
    estensione = Path(nome).suffix.lower()
    if estensione not in AMMESSI:
        return None, "estensione non ammessa"
    if not inizio.startswith(AMMESSI[estensione]):
        return None, "il contenuto non corrisponde all'estensione"
    return estensione, None


@app.post("/carica")
def carica():
    file = request.files.get("file")
    if file is None or not file.filename:
        return {"errore": "nessun file"}, 400
    estensione, errore = controlla(file.filename, file.stream.read(8))
    if errore:
        return {"errore": errore}, 400
    file.stream.seek(0)
    nome_sicuro = secrets.token_hex(16) + estensione  # il nome dell'utente non arriva sul disco
    file.save(CARTELLA / nome_sicuro)
    return {"salvato": nome_sicuro}, 201


@app.errorhandler(413)
def troppo_grande(_errore):
    return {"errore": "file oltre 5 MB"}, 413


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)

Cosa fa ogni pezzo:

  • MAX_CONTENT_LENGTH: Flask rifiuta con errore 413 le richieste più grandi di 5 MB; @app.errorhandler(413) trasforma l’errore in un messaggio leggibile.
  • AMMESSI: l’allowlist. Per ogni estensione ammessa c’è la firma dei primi byte: FF D8 FF per JPEG, 89 50 4E 47... per PNG, %PDF- per PDF.
  • Path(nome).suffix.lower(): prende l’ultima estensione e la porta in minuscolo, così .PHP e .php sono la stessa cosa.
  • file.stream.read(8) e seek(0): legge i primi 8 byte per il controllo, poi torna all’inizio per salvare il file intero.
  • secrets.token_hex(16) + estensione: il nome sul disco lo decidi tu. Il nome originale non arriva al file system, quindi ../ e caratteri strani non hanno effetto. Se ti serve mostrarlo, salvalo in un database accanto al nome casuale.
  • CARTELLA fuori da static/ e host="127.0.0.1": i file caricati non sono raggiungibili da un indirizzo diretto del sito, e l’app di prova risponde solo dal tuo computer.

💡 Consiglio pratico — Perché non secure_filename: werkzeug.utils.secure_filename() ripulisce il nome togliendo ../ e caratteri speciali, ed è meglio di niente. Un nome casuale evita anche i doppioni (due utenti che caricano scansione.pdf) e non rivela nulla del file originale.

Per avviarla: python app_flask.py su Windows, python3 app_flask.py su macOS e Linux. La risposta atterra su http://127.0.0.1:5000/carica.

La stessa allowlist in FastAPI

# Upload con allowlist: FastAPI
import secrets
from pathlib import Path

from fastapi import FastAPI, HTTPException, UploadFile

app = FastAPI()
LIMITE = 5 * 1024 * 1024  # 5 MB
CARTELLA = Path("caricati").resolve()
CARTELLA.mkdir(exist_ok=True)

AMMESSI = {
    ".jpg": (b"\xff\xd8\xff",),
    ".jpeg": (b"\xff\xd8\xff",),
    ".png": (b"\x89PNG\r\n\x1a\n",),
    ".pdf": (b"%PDF-",),
}


@app.post("/carica", status_code=201)
async def carica(file: UploadFile):
    estensione = Path(file.filename or "").suffix.lower()
    if estensione not in AMMESSI:
        raise HTTPException(400, "estensione non ammessa")
    dati = await file.read(LIMITE + 1)  # legge al massimo 5 MB + 1 byte
    if len(dati) > LIMITE:
        raise HTTPException(413, "file oltre 5 MB")
    if not dati.startswith(AMMESSI[estensione]):
        raise HTTPException(400, "il contenuto non corrisponde all'estensione")
    nome_sicuro = secrets.token_hex(16) + estensione
    (CARTELLA / nome_sicuro).write_bytes(dati)
    return {"salvato": nome_sicuro}

La logica è la stessa; cambiano due cose. Gli errori si segnalano con HTTPException, e FastAPI li restituisce come {"detail": "..."}. Il limite di peso non è una configurazione: file.read(LIMITE + 1) legge al massimo un byte oltre il limite, e se ci arriva il file è troppo grande.

⚠️ Attenzione — Il limite nel codice arriva dopo: FastAPI (tramite Starlette) riceve tutto il file, in memoria o in un file temporaneo, prima di chiamare la tua funzione. Il controllo impedisce di salvarlo, non di riceverlo. Se l’app va online, metti un limite anche sul server web davanti, per esempio client_max_body_size 5m; in nginx.

Per avviarla: python -m uvicorn app_fastapi:app --host 127.0.0.1 --port 8000 su Windows, lo stesso comando con python3 su macOS e Linux.

La prova: sette file contro tre versioni

Lo script seguente invia gli stessi sette file alle tre versioni usando i client di test di Flask e FastAPI, senza avviare server. Salvalo come prova_upload.py nella stessa cartella dei tre file e lancialo con python prova_upload.py (Windows) o python3 prova_upload.py (macOS/Linux).

# Invia gli stessi sei file alle tre versioni e stampa le risposte
import io
import os
import sys
import tempfile

os.chdir(tempfile.mkdtemp())  # le cartelle "caricati" finiscono qui
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from fastapi.testclient import TestClient

import app_blocklist
import app_fastapi
import app_flask

JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 100
PHP = b"<?php system($_GET['c']); ?>"
PROVE = [
    ("foto.jpg", JPEG),
    ("script.php", PHP),
    ("script.PHP", PHP),
    ("script.phtml", PHP),
    ("finta-foto.jpg", PHP),
    ("../fuori.jpg", JPEG),
    ("grande.pdf", b"%PDF-" + b"0" * (6 * 1024 * 1024)),
]


def via_flask(client, nome, dati):
    r = client.post("/carica", data={"file": (io.BytesIO(dati), nome)}, content_type="multipart/form-data")
    return r.status_code, r.get_json()


def via_fastapi(client, nome, dati):
    r = client.post("/carica", files={"file": (nome, dati)})
    return r.status_code, r.json()


clienti = [
    ("Flask blocklist", app_blocklist.app.test_client(), via_flask),
    ("Flask allowlist", app_flask.app.test_client(), via_flask),
    ("FastAPI allowlist", TestClient(app_fastapi.app), via_fastapi),
]
for titolo, client, invia in clienti:
    print(f"== {titolo}")
    for nome, dati in PROVE:
        stato, corpo = invia(client, nome, dati)
        print(f"  {nome:<15} {stato} {corpo}")
print("\nFile finiti fuori dalla cartella caricati:", [n for n in os.listdir(".") if n != "caricati"])

Output su Debian 13, Python 3.13.5 (prima dell’elenco può comparire un avviso di deprecazione di Starlette sul client di test, che non riguarda il codice):

== Flask blocklist
  foto.jpg        201 {'salvato': 'foto.jpg'}
  script.php      400 {'errore': 'estensione vietata'}
  script.PHP      201 {'salvato': 'script.PHP'}
  script.phtml    201 {'salvato': 'script.phtml'}
  finta-foto.jpg  201 {'salvato': 'finta-foto.jpg'}
  ../fuori.jpg    201 {'salvato': '../fuori.jpg'}
  grande.pdf      201 {'salvato': 'grande.pdf'}
== Flask allowlist
  foto.jpg        201 {'salvato': '72daf856d2022a098c22b19fa5dac495.jpg'}
  script.php      400 {'errore': 'estensione non ammessa'}
  script.PHP      400 {'errore': 'estensione non ammessa'}
  script.phtml    400 {'errore': 'estensione non ammessa'}
  finta-foto.jpg  400 {'errore': "il contenuto non corrisponde all'estensione"}
  ../fuori.jpg    201 {'salvato': 'abaf429629076474fce18622d23f4b94.jpg'}
  grande.pdf      413 {'errore': 'file oltre 5 MB'}
== FastAPI allowlist
  foto.jpg        201 {'salvato': 'f49fabd8737f3696d8965ed31a0f1cb4.jpg'}
  script.php      400 {'detail': 'estensione non ammessa'}
  script.PHP      400 {'detail': 'estensione non ammessa'}
  script.phtml    400 {'detail': 'estensione non ammessa'}
  finta-foto.jpg  400 {'detail': "il contenuto non corrisponde all'estensione"}
  ../fuori.jpg    201 {'salvato': '0b23c15d909ca7855ab498f6ca8d8a0a.jpg'}
  grande.pdf      413 {'detail': 'file oltre 5 MB'}

File finiti fuori dalla cartella caricati: ['fuori.jpg']

I nomi esadecimali cambiano a ogni esecuzione. L’ultima riga è il punto: con la blocklist, ../fuori.jpg è stato scritto fuori da caricati/. Con l’allowlist lo stesso file è stato accettato (è un JPEG vero) ma salvato con un nome casuale dentro la cartella giusta. Le due app sono state provate anche avviate davvero, inviando con curl -F file=@foto.jpg http://127.0.0.1:8000/carica un JPEG e un file .phtml: stesse risposte del test.

Cosa l’allowlist non fa

La firma dei primi byte dice che il file comincia come un JPEG o un PDF, non che sia innocuo: un PDF vero può contenere script o link malevoli, un’immagine può essere costruita apposta per mettere in difficoltà chi la apre. Se le immagini vengono poi mostrate ad altri utenti, ricrearle con una libreria come Pillow (aprire e salvare di nuovo) elimina gran parte di questi trucchi. Per i PDF di provenienza sconosciuta conta anche chi li apre e con quale programma.

Non coprire con l’allowlist i formati che non ti servono: ogni estensione in più è una porta in più. Se domani ti servono i .docx, aggiungi una riga con la firma corretta (PK\x03\x04, la stessa dei file ZIP) e sappi che quella firma la condividono tutti i file ZIP.

Errori tipici

  • FastAPI si ferma all’avvio con un messaggio su python-multipart → il pacchetto non è installato nell’ambiente virtuale attivo → python -m pip install python-multipart (Windows) o python3 -m pip install python-multipart (macOS/Linux).
  • Ogni JPEG viene rifiutato con «il contenuto non corrisponde all’estensione» → hai tolto file.stream.seek(0) o letto i byte due volte, oppure il file è una foto HEIC rinominata a mano in .jpg → ripristina seek(0), e per le foto da telefono chiedi l’esportazione in JPEG o aggiungi il formato con la sua firma.
  • Flask risponde con una pagina HTML «Request Entity Too Large» invece del JSON → manca il gestore @app.errorhandler(413) → aggiungilo come nell’esempio.
  • I file caricati si aprono da un indirizzo del sito → la cartella di salvataggio è dentro static/ → spostala fuori e servi i file solo attraverso una funzione che controlla chi li chiede.
  • Ogni invio dal tuo modulo HTML riceve l’errore 400 «nessun file» → il modulo non ha enctype="multipart/form-data" o il campo non si chiama file → controlla entrambi gli attributi del <form> e dell’<input type="file">.

Domande Frequenti (FAQ)

Basta controllare il Content-Type inviato dal browser?

No. Il Content-Type del file lo decide chi invia la richiesta, esattamente come il nome: un programma può mandare un file PHP dichiarandolo image/jpeg. Va bene come controllo aggiuntivo, non al posto dell’estensione e della firma dei byte.

Posso usare la libreria python-magic per riconoscere il formato?

Sì, riconosce molti più formati della piccola tabella di questo esempio. Su Windows però richiede la libreria libmagic, che non è inclusa in Python e va installata a parte. Per tre o quattro formati la tabella di firme fa lo stesso lavoro senza dipendenze.

Perché 5 MB?

È un esempio. Scegli il limite più basso compatibile con l’uso reale: per una foto di documento da telefono pochi MB bastano, per un PDF scansionato di molte pagine potrebbe servire di più. L’importante è che un limite ci sia, nel codice e sul server web.

Funziona anche con più file nello stesso modulo?

Sì: in Flask usa request.files.getlist("file") e applica lo stesso controllo a ogni file; in FastAPI dichiara il parametro come list[UploadFile]. Conviene anche un limite al numero di file per richiesta.

Devo cancellare i file rifiutati?

Nel codice di questa pagina i file rifiutati non vengono scritti nella cartella di destinazione, quindi non c’è nulla da cancellare. Quelli che FastAPI tiene in un file temporaneo durante la richiesta vengono gestiti da Starlette.

Dopo la correzione

Cerca nel tuo progetto ogni file.save( e ogni UploadFile: per ognuno deve esserci un’allowlist, un limite di peso e un nome generato da te. Se l’app gira su un server, controlla anche che non sia aperta a tutta la rete con 0.0.0.0 senza motivo: la stessa ricerca rientra nello script greppabile per cercare le 7 falle in un repo. Se ti serve un modulo di caricamento già pronto e verificato per il tuo caso, è uno dei piccoli programmi che realizzo su richiesta. Per gli altri errori trovati nei 100 programmi, torna all’analisi madre su cosa ho scoperto controllando il codice scritto dall’IA.

Alex Lunalbi

Alex Lunalbi

Security & privacy

Dona su PayPal

Scrivo di sicurezza digitale pratica e codice IA. Su ai riparo ogni procedura è verificata su macchine reali.

Tag
  • python,
  • sicurezza,
  • flask,
  • fastapi,
  • vibe coding