In questa lezione impari a
- capire come funziona un attacco di SQL injection
- scrivere query preparate con mysqli_prepare, mysqli_stmt_bind_param e mysqli_stmt_execute
- leggere i risultati con mysqli_stmt_get_result
- gestire LIKE, LIMIT e i valori NULL nelle query preparate
- usare la lista bianca per ciò che non si può parametrizzare
- riconoscere le stesse query a oggetti e con PDO
Nella lezione precedente abbiamo eseguito solo query scritte interamente da noi. Ma un'applicazione vera mette nelle query i dati dell'utente: il testo da cercare, l'id del libro da mostrare, lo username del login. Se lo si fa nel modo sbagliato, si apre la porta all'attacco più famoso (e ancora oggi tra i più diffusi) del web: la SQL injection.
L'attacco
Ecco una ricerca scritta nel modo sbagliato: il testo del form viene incollato dentro la stringa SQL.
<?php
// ❌ NON FARE MAI COSÌ
require_once 'db.php';
$q = $_GET['q'] ?? '';
$sql = "SELECT titolo, anno FROM libri WHERE titolo LIKE '%$q%'";
$libri = mysqli_fetch_all(mysqli_query($conn, $sql), MYSQLI_ASSOC);
Da dove arriva
$q? Dal form di ricerca della lezione 9. Il campo si chiamaq(da query, "domanda": è lo stesso nome che usa Google,google.com/search?q=…):<form action="cerca.php" method="get"> <input type="search" name="q"> <button>Cerca</button> </form>Chi scrive
robote preme Cerca apre l'indirizzocerca.php?q=robot. PHP mette i parametri dell'indirizzo nell'array$_GET, con ilnamedel campo come chiave:$_GET['q']vale'robot'. La prima riga dello script lo copia in una variabile normale,$q:
?? ''evita l'errore quando la pagina si apre senza ricerca (cerca.phpe basta: la chiaveqnon c'è);- con un nome corto il resto del codice si legge meglio, e il valore si "pulisce" una volta sola (nella versione corretta, più sotto, con
trim);- la stessa variabile serve più volte: nella query e per rimettere il testo cercato nel campo (
value="<?= h($q) ?>").Il punto importante per questa lezione:
$qcontiene quello che ha scritto l'utente, qualunque cosa sia.
Con una ricerca normale, robot, la query diventa:
SELECT titolo, anno FROM libri WHERE titolo LIKE '%robot%'
e trova Io, robot. Ma l'utente può scrivere quello che vuole. Se cerca ' OR 1=1 -- (con uno spazio finale), la query diventa:
SELECT titolo, anno FROM libri WHERE titolo LIKE '%' OR 1=1 -- %'
L'apice dell'utente ha chiuso la stringa: ora OR 1=1 è un pezzo di SQL, sempre vero, e -- trasforma in commento il resto. La query restituisce tutti i libri. Fin qui poco male. Ma se cerca:
x' UNION SELECT username, password_hash FROM utenti --
la query diventa una UNION che aggiunge ai risultati le righe di un'altra tabella: nella pagina, al posto dei titoli, compaiono i nomi degli utenti e le impronte delle loro password. Con la stessa tecnica si possono leggere tutte le tabelle del database e, a seconda della configurazione, modificarle o cancellarle. Su un form di login, lo stesso trucco permette di entrare senza password.
Prova l'attacco sul tuo XAMPP, sulla tua copia del database: è il modo migliore per capirlo. Provarlo su siti di altri è un reato (accesso abusivo a un sistema informatico, art. 615-ter del Codice penale), anche "solo per vedere se funziona".
Il problema non è l'apice: è che dati e comandi viaggiano mescolati nella stessa stringa, e il database non può sapere quale parte ha scritto il programmatore e quale l'utente.
La soluzione: le query preparate
Con una query preparata (prepared statement) la query e i dati viaggiano separati:
- prepare: si manda a MySQL la query con dei segnaposto
?al posto dei valori. MySQL la analizza e la "congela": da questo momento la sua struttura non può più cambiare; - bind: si legano ai segnaposto le variabili PHP, dichiarando di che tipo sono;
- execute: si mandano i valori. MySQL li tratta sempre e solo come dati, qualunque cosa contengano.
<?php
// ✅ COSÌ
require_once 'db.php';
$q = trim($_GET['q'] ?? '');
$cerca = "%$q%";
$stmt = mysqli_prepare($conn, 'SELECT titolo, anno FROM libri WHERE titolo LIKE ? ORDER BY titolo');
mysqli_stmt_bind_param($stmt, 's', $cerca);
mysqli_stmt_execute($stmt);
$risultato = mysqli_stmt_get_result($stmt);
$libri = mysqli_fetch_all($risultato, MYSQLI_ASSOC);
Ora se l'utente cerca ' OR 1=1 -- , MySQL cerca un titolo che contiene letteralmente quei caratteri, apice compreso. Non lo trova, e la ricerca restituisce zero righe. L'attacco non funziona più, qualunque cosa venga scritta.
Nota anche che non serve più raddoppiare gli apici: un titolo come L'isola di Arturo si cerca senza problemi.
Le quattro righe si ripetono sempre uguali:
| Passo | Funzione | Cosa fa |
|---|---|---|
| 1 | mysqli_prepare($conn, $sql) |
manda la query con i ? e restituisce lo statement ($stmt) |
| 2 | mysqli_stmt_bind_param($stmt, 'tipi', $a, $b…) |
lega le variabili ai ?, nell'ordine |
| 3 | mysqli_stmt_execute($stmt) |
esegue la query con quei valori |
| 4 | mysqli_stmt_get_result($stmt) |
solo per le SELECT: restituisce il risultato, da leggere con mysqli_fetch_all o mysqli_fetch_assoc come nella lezione 10 |
I tipi di bind_param. La stringa 's' dice che il primo (e unico) valore è una stringa. Serve una lettera per ogni ?, nell'ordine:
| Lettera | Tipo | Esempio |
|---|---|---|
s |
stringa (string) | testi, date, email |
i |
intero (integer) | id, anni, quantità |
d |
decimale (double) | prezzi, medie |
b |
dati binari (blob) | immagini salvate nel database (raro) |
mysqli_stmt_bind_paramvuole variabili, non valori scritti direttamente:mysqli_stmt_bind_param($stmt, 's', "%$q%")dà errore. Per questo prima si prepara$cerca = "%$q%"e poi si lega$cerca.
La regola del corso. Ogni volta che in una query entra un valore che arriva da fuori (
$_GET,$_POST,$_COOKIE, un file caricato, persino il database stesso), si usa una query preparata. Sempre, anche quando "tanto è un numero".mysqli_query()si usa solo per le query scritte tutte da noi.
Lo stesso a oggetti (mysqli)
<?php
$cerca = "%$q%";
$stmt = $conn->prepare('SELECT titolo, anno FROM libri WHERE titolo LIKE ? ORDER BY titolo');
$stmt->bind_param('s', $cerca);
$stmt->execute();
$libri = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
Lo stesso con PDO
<?php
$stmt = $pdo->prepare('SELECT titolo, anno FROM libri WHERE titolo LIKE ? ORDER BY titolo');
$stmt->execute(["%$q%"]);
$libri = $stmt->fetchAll(PDO::FETCH_ASSOC);
PDO non ha il passaggio di bind: i valori si passano in un array direttamente a execute, e non serve dichiarare i tipi. Accetta anche segnaposto con nome, più leggibili quando i valori sono tanti:
<?php
$stmt = $pdo->prepare('INSERT INTO studenti (nome, cognome, classe, email)
VALUES (:nome, :cognome, :classe, :email)');
$stmt->execute([':nome' => 'Davide', ':cognome' => 'Conti', ':classe' => '5B', ':email' => 'davide.conti@scuola.it']);
echo 'Nuovo studente con id ' . $pdo->lastInsertId();
Più valori
I punti interrogativi si riempiono nell'ordine delle variabili, e i tipi seguono lo stesso ordine:
<?php
$genere = 'Fantascienza';
$anno = 1900;
$stmt = mysqli_prepare($conn, 'SELECT titolo FROM libri WHERE genere = ? AND anno > ? ORDER BY anno');
mysqli_stmt_bind_param($stmt, 'si', $genere, $anno); // s = stringa, i = intero
mysqli_stmt_execute($stmt);
$libri = mysqli_fetch_all(mysqli_stmt_get_result($stmt), MYSQLI_ASSOC); // 1984, Io, robot, Fondazione
INSERT, UPDATE, DELETE
Per le istruzioni che modificano i dati non c'è un risultato da leggere: bastano i primi tre passi.
<?php
$nome = 'Davide';
$cognome = 'Conti';
$classe = '5B';
$email = 'davide.conti@scuola.it';
$stmt = mysqli_prepare($conn, 'INSERT INTO studenti (nome, cognome, classe, email) VALUES (?, ?, ?, ?)');
mysqli_stmt_bind_param($stmt, 'ssss', $nome, $cognome, $classe, $email);
mysqli_stmt_execute($stmt);
echo 'Nuovo studente con id ' . mysqli_insert_id($conn);
echo ' (righe inserite: ' . mysqli_stmt_affected_rows($stmt) . ')';
mysqli_insert_id($conn)restituisce l'idAUTO_INCREMENTappena assegnato: serve, per esempio, per andare alla scheda del record appena creato.mysqli_stmt_affected_rows($stmt)dice quante righe sono state inserite, modificate o cancellate: con unUPDATE … WHERE id = ?, 0 significa che quell'id non esiste (o che i valori erano già uguali).
Una riga sola: il dettaglio
Il caso più comune in assoluto: la pagina libro.php?id=7 mostra un libro.
<?php
require_once 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$stmt = mysqli_prepare($conn, 'SELECT l.*, a.nome, a.cognome
FROM libri l JOIN autori a ON a.id = l.autore_id
WHERE l.id = ?');
mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);
$libro = mysqli_fetch_assoc(mysqli_stmt_get_result($stmt));
if (!$libro) {
http_response_code(404);
exit('Libro non trovato.');
}
echo htmlspecialchars($libro['titolo'] . ' di ' . $libro['nome'] . ' ' . $libro['cognome']);
(int)trasforma il parametro in intero ('7abc'diventa 7,'ciao'diventa 0): non è la difesa contro l'injection (quella è la query preparata), ma evita valori senza senso.mysqli_fetch_assocrestituiscenullse il libro non esiste: in quel caso si risponde con il codice HTTP 404 e un messaggio, invece di una pagina piena di errori.
Una scorciatoia (PHP 8.2 e successivi).
mysqli_execute_query($conn, $sql, [$valore1, $valore2])fa prepare, bind ed execute in una riga sola, senza le lettere dei tipi:$libro = mysqli_fetch_assoc(mysqli_execute_query($conn, 'SELECT * FROM libri WHERE id = ?', [$id]));. È comoda, ma nei manuali e nelle tracce trovi quasi sempre la forma lunga: impara prima quella.
Casi particolari
LIKE. I caratteri jolly % si mettono nel valore, non nella query: LIKE ? con $cerca = "%$q%". Scrivere LIKE '%?%' non funziona: il punto interrogativo tra apici è un normale carattere.
LIMIT e OFFSET. Si legano come interi, con la lettera i:
<?php
$quanti = 5;
$salta = 10;
$stmt = mysqli_prepare($conn, 'SELECT titolo FROM libri ORDER BY titolo LIMIT ? OFFSET ?');
mysqli_stmt_bind_param($stmt, 'ii', $quanti, $salta);
mysqli_stmt_execute($stmt);
NULL. Per salvare NULL (per esempio un anno non indicato) si lega una variabile che vale null: $anno = $_POST['anno'] === '' ? null : (int) $_POST['anno']; e poi bind_param('…i…', …, $anno, …). MySQL riceve NULL, qualunque sia la lettera del tipo.
Nomi di colonne e tabelle. I segnaposto sostituiscono solo valori, non pezzi di SQL. ORDER BY ? non ordina per la colonna scelta dall'utente (ordina per una costante, cioè non ordina). Quando l'utente sceglie l'ordinamento, si usa una lista bianca: si accettano solo i valori previsti e si mette nella query il testo scritto da noi.
<?php
$ordinamenti = [
'titolo' => 'l.titolo',
'anno' => 'l.anno DESC',
'autore' => 'a.cognome, l.titolo',
];
$scelta = $_GET['ordine'] ?? 'titolo';
$order_by = $ordinamenti[$scelta] ?? $ordinamenti['titolo']; // valore sconosciuto → predefinito
$risultato = mysqli_query($conn, "SELECT l.titolo, l.anno, a.cognome
FROM libri l JOIN autori a ON a.id = l.autore_id
ORDER BY $order_by");
$libri = mysqli_fetch_all($risultato, MYSQLI_ASSOC);
Qui mysqli_query() è corretto: nella stringa entra solo testo scelto da noi, mai quello dell'utente.
Riepilogo: tre modi, la stessa idea
| mysqli a funzioni | mysqli a oggetti | PDO | |
|---|---|---|---|
| Preparare | mysqli_prepare($conn, $sql) |
$conn->prepare($sql) |
$pdo->prepare($sql) |
| Segnaposto | solo ? |
solo ? |
? oppure :nome |
| Passare i valori | mysqli_stmt_bind_param($stmt, 'si', $a, $b) |
$stmt->bind_param('si', $a, $b) |
un array in execute([...]) |
| Eseguire | mysqli_stmt_execute($stmt) |
$stmt->execute() |
$stmt->execute([...]) |
| Leggere | mysqli_stmt_get_result($stmt), poi mysqli_fetch_… |
$stmt->get_result()->fetch_… |
direttamente $stmt->fetchAll() |
| Ultimo id | mysqli_insert_id($conn) |
$conn->insert_id |
$pdo->lastInsertId() |
| Righe modificate | mysqli_stmt_affected_rows($stmt) |
$stmt->affected_rows |
$stmt->rowCount() |
Su vecchi codici troverai anche
mysqli_real_escape_string($conn, $q), che "protegge" gli apici prima di incollare il valore nella query. Funziona solo se usata ovunque e nel modo giusto (e non protegge i numeri non tra apici): basta dimenticarla una volta. Le query preparate sono la soluzione corretta.
Esercizi
Esercizio 1 – Attacca e ripara. Crea cerca_insicuro.php con la ricerca sbagliata dell'inizio della lezione e un piccolo form GET. Prova i due attacchi della lezione. Poi crea cerca.php con la query preparata e ripeti le prove. Cosa succede in entrambi i casi cercando L'isola?
Mostra la soluzione
<?php
// cerca.php
require_once 'db.php';
$q = trim($_GET['q'] ?? '');
$libri = [];
if ($q !== '') {
$cerca = "%$q%";
$stmt = mysqli_prepare($conn, 'SELECT titolo, anno FROM libri WHERE titolo LIKE ? ORDER BY titolo');
mysqli_stmt_bind_param($stmt, 's', $cerca);
mysqli_stmt_execute($stmt);
$libri = mysqli_fetch_all(mysqli_stmt_get_result($stmt), MYSQLI_ASSOC);
}
?>
<form method="get">
<input type="search" name="q" value="<?= htmlspecialchars($q) ?>">
<button>Cerca</button>
</form>
<?php foreach ($libri as $l): ?>
<p><?= htmlspecialchars($l['titolo']) ?> (<?= $l['anno'] ?>)</p>
<?php endforeach; ?>
- Nella versione insicura,
' OR 1=1 --mostra tutti i 14 libri e l'attacco conUNIONmostraadmincon l'impronta della sua password. - Nella versione preparata entrambi gli attacchi danno zero risultati.
L'isola: la versione insicura va in errore di sintassi (l'apice chiude la stringa e il resto non è SQL valido). È già un campanello d'allarme: se un apice rompe la pagina, la pagina è vulnerabile. La versione preparata trova L'isola di Arturo.
Esercizio 2 – I libri di un autore. Crea autore.php?id=… che mostra nome e cognome dell'autore e l'elenco dei suoi libri. Se l'autore non esiste, rispondi con 404. Nella pagina autori.php della lezione precedente, trasforma ogni cognome in un link alla pagina dell'autore.
Mostra la soluzione
<?php
require_once 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$stmt = mysqli_prepare($conn, 'SELECT nome, cognome FROM autori WHERE id = ?');
mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);
$autore = mysqli_fetch_assoc(mysqli_stmt_get_result($stmt));
if (!$autore) {
http_response_code(404);
exit('Autore non trovato.');
}
$stmt = mysqli_prepare($conn, 'SELECT titolo, anno FROM libri WHERE autore_id = ? ORDER BY anno');
mysqli_stmt_bind_param($stmt, 'i', $id);
mysqli_stmt_execute($stmt);
$libri = mysqli_fetch_all(mysqli_stmt_get_result($stmt), MYSQLI_ASSOC);
?>
<h1><?= htmlspecialchars($autore['nome'] . ' ' . $autore['cognome']) ?></h1>
<ul>
<?php foreach ($libri as $l): ?>
<li><?= htmlspecialchars($l['titolo']) ?> (<?= $l['anno'] ?>)</li>
<?php endforeach; ?>
</ul>
In autori.php la query deve leggere anche l'id, e la cella diventa:
<td><a href="autore.php?id=<?= $a['id'] ?>"><?= htmlspecialchars($a['cognome']) ?></a></td>
Per Primo Levi (id = 1) compaiono tre libri, dal 1947 al 1978.
Esercizio 3 – Il login vulnerabile. Un compagno ha scritto questo controllo. Spiega perché con lo username admin' -- si entra senza password e riscrivilo con una query preparata. (Nella lezione 13 vedremo che c'è anche un altro problema: la password non va mai salvata né confrontata in chiaro.)
<?php
$u = $_POST['username'];
$p = $_POST['password'];
$riga = mysqli_fetch_assoc(mysqli_query($conn, "SELECT * FROM utenti WHERE username = '$u' AND password = '$p'"));
if ($riga) { echo 'Benvenuto!'; }
Mostra la soluzione
Con admin' -- la query diventa:
SELECT * FROM utenti WHERE username = 'admin' -- ' AND password = '...'
Tutto quello che segue -- è un commento: il controllo della password sparisce e la query trova l'utente admin.
<?php
$u = $_POST['username'] ?? '';
$p = $_POST['password'] ?? '';
$stmt = mysqli_prepare($conn, 'SELECT * FROM utenti WHERE username = ? AND password = ?');
mysqli_stmt_bind_param($stmt, 'ss', $u, $p);
mysqli_stmt_execute($stmt);
$riga = mysqli_fetch_assoc(mysqli_stmt_get_result($stmt));
Ora l'injection non funziona. Resta il problema della password in chiaro nel database: lo risolveremo con password_hash e password_verify.
Esercizio 4 – Ordina il catalogo. Aggiungi a cerca.php un menu "Ordina per" (titolo, anno, autore) usando la lista bianca. Prova a modificare a mano l'indirizzo con ?ordine=anno;DROP TABLE libri: cosa succede?
Mostra la soluzione
Si usa l'array $ordinamenti della lezione e un <select name="ordine"> con i tre valori. La query unisce il WHERE preparato e l'ORDER BY della lista bianca:
<?php
$cerca = "%$q%";
$stmt = mysqli_prepare($conn, "SELECT l.titolo, l.anno, a.cognome
FROM libri l JOIN autori a ON a.id = l.autore_id
WHERE l.titolo LIKE ?
ORDER BY $order_by");
mysqli_stmt_bind_param($stmt, 's', $cerca);
mysqli_stmt_execute($stmt);
Con ?ordine=anno;DROP TABLE libri non succede nulla di male: quel valore non è una chiave dell'array $ordinamenti, quindi ?? sceglie l'ordinamento predefinito per titolo. Nella query entra solo testo scritto da noi.