Una parte sorprendentemente ampia delle vulnerabilità del software ha la stessa origine: il programma si fida di dati che non dovrebbe considerare affidabili. Un campo di un modulo, un parametro in un indirizzo web, un file caricato, la risposta di un servizio esterno. Nel mio lavoro sulla sicurezza, molti dei problemi che incontro non richiedono tecniche sofisticate per essere evitati: basterebbe un'abitudine costante a trattare ogni input esterno come sospetto finché non è stato verificato.
Il principio di fondo
Ogni dato che arriva da fuori dal confine del tuo programma è non attendibile. Non perché tutti gli utenti siano malintenzionati, ma perché il codice non può distinguere un errore di battitura da un tentativo di attacco. Rientrano in questa categoria:
- tutto ciò che arriva da richieste HTTP: parametri, intestazioni, cookie, corpo della richiesta;
- file caricati dagli utenti, compresi nome, dimensione e contenuto;
- dati letti da database condivisi con altri sistemi;
- risposte di API esterne;
- variabili d'ambiente e argomenti da riga di comando, in contesti in cui altri possono controllarli.
Un dettaglio spesso dimenticato: i controlli fatti nel browser, con JavaScript o con gli attributi dei moduli HTML, migliorano l'esperienza dell'utente ma non proteggono nulla. Chiunque può inviare una richiesta direttamente al server, saltandoli. La validazione che conta è quella lato server.
Lista di ammessi, non lista di vietati
Esistono due approcci alla validazione:
| Approccio | Come funziona | Problema |
|---|---|---|
| Lista di vietati (blocklist) | Rifiuta i valori noti come pericolosi | Impossibile prevedere tutte le varianti |
| Lista di ammessi (allowlist) | Accetta solo valori conformi a regole precise | Richiede di definire chiaramente ciò che è valido |
L'approccio della lista di ammessi è quasi sempre preferibile. Se un campo deve contenere un codice postale italiano, si accettano esattamente cinque cifre; tutto il resto viene rifiutato. Se un parametro indica l'ordinamento di una tabella, si accettano solo i nomi di colonna previsti:
COLONNE_ORDINABILI = {"data", "importo", "cliente"}
def ordina_per(colonna):
if colonna not in COLONNE_ORDINABILI:
raise ValueError(f"Colonna di ordinamento non valida: {colonna!r}")
return colonna
Validare ai confini
La validazione va fatta all'ingresso, nel punto in cui il dato entra nel sistema, trasformandolo il prima possibile in un tipo preciso. Invece di far circolare stringhe grezze per tutto il codice, conviene convertirle subito:
- una data in un oggetto data, rifiutando i formati non validi;
- un importo in un numero decimale, controllando che sia positivo e entro un limite ragionevole;
- un identificativo in un intero, verificando che esista e appartenga all'utente che lo richiede.
Librerie come Pydantic in Python permettono di dichiarare queste regole in modo compatto e leggibile, generando errori chiari quando i dati non le rispettano. Dopo il confine, il resto del codice lavora con dati già verificati e tipizzati.
Query parametrizzate, sempre
L'esempio più noto di fiducia mal riposta è l'SQL injection: un valore inserito dall'utente viene concatenato in una query e finisce per modificarne la struttura.
# Sbagliato: il valore diventa parte del codice SQL
cursore.execute(f"SELECT * FROM clienti WHERE email = '{email}'")
# Corretto: il valore viene passato separatamente come parametro
cursore.execute("SELECT * FROM clienti WHERE email = %s", (email,))
Con la query parametrizzata, il database riceve separatamente il testo della query e i valori, e non confonde mai gli uni con l'altro. Il segnaposto varia secondo il driver (%s, ?, :nome), ma il principio è identico. Lo stesso vale per i comandi di sistema: passare gli argomenti come lista a subprocess.run, senza shell=True, evita che caratteri speciali vengano interpretati dalla shell.
Percorsi di file
Quando un parametro determina quale file leggere, un valore come ../../config/segreti.env può far uscire il programma dalla cartella prevista. La difesa consiste nel risolvere il percorso completo e verificare che stia ancora dentro la cartella consentita:
from pathlib import Path
BASE = Path("/srv/documenti").resolve()
def percorso_sicuro(nome):
p = (BASE / nome).resolve()
if not p.is_relative_to(BASE):
raise PermissionError("Percorso non consentito")
return p
Ancora meglio, quando possibile, è non usare affatto nomi forniti dall'utente: si salva il file con un identificativo generato dal sistema e si conserva il nome originale solo come dato descrittivo.
Segreti fuori dal codice
Una regola collegata: password, chiavi API e token non devono comparire nel codice sorgente né nel repository Git. Una volta committato, un segreto resta nella storia anche se il file viene modificato in seguito. Vanno letti da variabili d'ambiente o da un gestore di segreti, e il file locale che li contiene, come .env, va escluso con .gitignore. Se un segreto finisce per errore in un repository, la sola correzione affidabile è revocarlo e generarne uno nuovo.
Messaggi di errore misurati
Quando un input viene rifiutato, l'utente deve capire cosa correggere, ma il messaggio non deve rivelare dettagli interni. «Il codice postale deve contenere cinque cifre» è utile; uno stack trace con i nomi delle tabelle del database mostrato nella pagina è un regalo a chi cerca punti deboli. I dettagli tecnici vanno nei log, non nella risposta.
In sintesi
- Considera non attendibile ogni dato che arriva dall'esterno, e valida sempre lato server.
- Preferisci le liste di valori ammessi alle liste di valori vietati.
- Valida e converti i dati ai confini del sistema, trasformandoli subito in tipi precisi.
- Usa sempre query parametrizzate e controlla che i percorsi di file restino nella cartella prevista.
- Tieni i segreti fuori dal codice e mostra all'utente errori chiari ma privi di dettagli interni.