Allowlist upload file con Flask/FastAPI (anti-blocklist)
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 epython3. Controlla conpython --version(Windows) opython3 --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.
# 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 FFper JPEG,89 50 4E 47...per PNG,%PDF-per PDF.Path(nome).suffix.lower(): prende l’ultima estensione e la porta in minuscolo, così.PHPe.phpsono la stessa cosa.file.stream.read(8)eseek(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.CARTELLAfuori dastatic/ehost="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 caricanoscansione.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) opython3 -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→ ripristinaseek(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 chiamafile→ 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.