La SQL injection è una delle vulnerabilità più note delle applicazioni web, eppure continua a comparire nel codice nuovo. La causa è quasi sempre la stessa: costruire una query unendo pezzi di testo fisso con dati forniti dall'utente. La soluzione è nota da tempo ed è semplice da applicare, a patto di capire perché funziona e dove non arriva.
Il problema: dati che diventano codice
Immaginiamo una funzione che cerca un cliente per email:
query = "SELECT * FROM clienti WHERE email = '" + email + "'"
cursore.execute(query)
Finché l'utente scrive un indirizzo normale, tutto funziona. Ma il database riceve un'unica stringa e non ha modo di distinguere la parte scritta dal programmatore da quella arrivata dal modulo. Se il valore inserito contiene un apice, l'apice chiude la stringa e ciò che segue viene interpretato come SQL. Il dato è diventato codice.
Anche senza intenzioni malevole, la concatenazione causa errori: un cognome come D'Angelo rompe la query. Questo è spesso il primo segnale che il codice è vulnerabile.
La soluzione: i parametri
Con una query parametrizzata il testo della query e i valori viaggiano separati:
cursore.execute(
"SELECT * FROM clienti WHERE email = %s",
(email,)
)
Il segnaposto %s (in altre librerie ?, :email o $1) indica al database che in quel punto andrà un valore. Il database analizza prima la struttura della query, poi riceve il valore e lo tratta sempre e solo come dato, qualunque carattere contenga. Un apice resta un apice dentro una stringa, non può chiudere nulla.
Il segnaposto non va confuso con la formattazione delle stringhe del linguaggio. Questa forma è ancora vulnerabile, anche se sembra simile:
# SBAGLIATO: è sempre concatenazione
cursore.execute("SELECT * FROM clienti WHERE email = '%s'" % email)
cursore.execute(f"SELECT * FROM clienti WHERE email = '{email}'")
La differenza sta nel passare i valori come argomento separato alla funzione di esecuzione, non nell'aspetto del segnaposto.
Perché l'escape manuale non basta
Una soluzione che si incontra spesso è «ripulire» l'input togliendo o raddoppiando gli apici. È fragile per diversi motivi:
- le regole di escape dipendono dal database, dalla codifica dei caratteri e dalla configurazione;
- basta dimenticare una sola query su cento per lasciare aperta la porta;
- i valori numerici non sono tra apici, quindi togliere gli apici non serve a niente;
- rende il codice più difficile da leggere e da verificare.
I parametri spostano la responsabilità sul driver e sul database, che conoscono le regole esatte.
Ciò che non si può parametrizzare
I parametri sostituiscono solo valori. Non possono sostituire nomi di tabelle, nomi di colonne, parole chiave come ASC o DESC, o parti della struttura della query. Il caso tipico è l'ordinamento scelto dall'utente in una tabella:
# Non funziona: il nome di colonna non è un valore
cursore.execute("SELECT * FROM prodotti ORDER BY %s", (colonna,))
Qui la soluzione corretta è una lista di valori ammessi:
COLONNE_AMMESSE = {"nome": "nome", "prezzo": "prezzo", "data": "data_inserimento"}
DIREZIONI = {"asc": "ASC", "desc": "DESC"}
col = COLONNE_AMMESSE.get(richiesta_colonna, "nome")
dir_ = DIREZIONI.get(richiesta_direzione, "ASC")
cursore.execute(f"SELECT * FROM prodotti ORDER BY {col} {dir_}")
Il testo inserito nella query proviene sempre dal dizionario scritto dal programmatore, mai direttamente dall'utente.
Il caso della clausola IN
Un elenco di valori di lunghezza variabile, come WHERE id IN (...), si gestisce generando il numero giusto di segnaposti e passando i valori come parametri. Molte librerie e ORM lo fanno in automatico.
ORM e query builder
Gli ORM, cioè le librerie che traducono oggetti in tabelle, usano i parametri internamente e proteggono la maggior parte delle operazioni. Quasi tutti, però, offrono un modo per scrivere SQL grezzo per le query complesse. In quei punti valgono esattamente le stesse regole: se si concatena, si torna vulnerabili. Nel mio lavoro, durante una revisione del codice, sono proprio questi punti i primi da controllare.
Difese nel database
Le query parametrizzate sono la difesa principale, ma conviene limitare i danni nel caso in cui qualcosa sfugga:
- Utenti con privilegi minimi. L'applicazione non deve collegarsi come amministratore. Un utente che può solo leggere e scrivere le tabelle necessarie non può cancellare il database né leggere tabelle di sistema.
- Utenti separati per funzioni diverse. Un modulo di sola consultazione può usare un utente con il solo permesso
SELECT. - Messaggi di errore generici. Gli errori dettagliati del database vanno scritti nei log, non mostrati all'utente, perché rivelano la struttura delle tabelle.
- Test automatici. Inserire nei test valori con apici, punti e virgola e caratteri speciali, verificando che vengano salvati e riletti identici.
In sintesi
- La SQL injection nasce dal concatenare dati dell'utente nel testo della query.
- Le query parametrizzate inviano struttura e valori separatamente: il valore resta sempre un dato.
- La formattazione di stringhe del linguaggio non è una query parametrizzata.
- Nomi di colonne e direzioni di ordinamento si gestiscono con liste di valori ammessi.
- Utenti del database con privilegi minimi limitano i danni di un eventuale errore.