Una ricerca che concatena il testo dell'utente dentro una query SQL fa entrare quel testo nella sintassi della query, e un apice basta a trasformarlo da dato a istruzione. La difesa è una sola e ha un nome noto, le query parametrizzate, ma ci sono tre punti in cui non basta da sola: i dati salvati e poi riusati, i nomi di colonna, e il fatto che validare l'input non ripara l'iniezione. In questo articolo li provi tutti su un database di prova e vedi il confine fra ciò che i parametri coprono e ciò che resta a te.
L'iniezione in poche righe
Secondo la guida di OWASP, una SQL injection avviene quando dati non fidati, di solito stringhe, diventano parte della sintassi di una query e ne cambiano la logica; la sorgente può essere una richiesta HTTP ma anche un'email, un servizio terzo o il database stesso. Nella Top 10 2025 è dentro A05, e la pagina ufficiale la descrive come rara ma di impatto alto. L'esempio più istruttivo del suo costo è MOVEit Transfer: l'avviso AA23-158A della CISA racconta che dal 27 maggio 2023 un gruppo ransomware sfruttò una vulnerabilità SQL injection sconosciuta (CVE-2023-34362) per installare una web shell e rubare dati dai database dei sistemi esposti.
Lo script crea in memoria, con il modulo node:sqlite di Node 24.11.0, un database con utenti, prodotti e una tabella di segreti finti; poi scrive ogni operazione due volte, la versione vulnerabile (stringa concatenata) e la corretta (valori separati). Il database è nel processo: non c'è server, non c'è rete, e l'unico dato è quello che inserisce lo script. Il login confronta anche l'hash della password: l'attacco mostrerà che quel controllo si può saltare.
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE utenti (id INTEGER PRIMARY KEY, email TEXT, hash TEXT, ruolo TEXT);
CREATE TABLE prodotti (id INTEGER PRIMARY KEY, nome TEXT, prezzo REAL);
CREATE TABLE segreti (id INTEGER PRIMARY KEY, nota TEXT);
INSERT INTO prodotti VALUES (1, 'Quaderno', 3.5), (2, 'Penna', 1.2);
INSERT INTO segreti VALUES (1, 'chiave-di-prova-123');
`);
const sha = (t) => createHash('sha256').update(t).digest('hex'); // solo per la prova: vedi l'articolo 04
db.prepare('INSERT INTO utenti (email, hash, ruolo) VALUES (?, ?, ?)')
.run('admin@example.test', sha('password-che-non-conosco'), 'admin');
// VULNERABILE: il testo dell'utente diventa parte della sintassi SQL
function loginVulnerabile(email, password) {
const sql = `SELECT id, ruolo FROM utenti WHERE email = '${email}' AND hash = '${sha(password)}'`;
return db.prepare(sql).get();
}
function cercaVulnerabile(termine) {
return db.prepare(`SELECT id, nome FROM prodotti WHERE nome LIKE '%${termine}%'`).all();
}
// CORRETTO: la struttura è fissa, i valori viaggiano a parte
function login(email, password) {
return db.prepare('SELECT id, ruolo FROM utenti WHERE email = ? AND hash = ?').get(email, sha(password));
}
function cerca(termine) {
return db.prepare('SELECT id, nome FROM prodotti WHERE nome LIKE ?').all(`%${termine}%`);
}
const attaccoLogin = "admin@example.test' --";
const attaccoUnion = "x' UNION SELECT id, nota FROM segreti --";
console.log('login vulnerabile :', JSON.stringify(loginVulnerabile(attaccoLogin, 'a caso')));
console.log('login corretto :', JSON.stringify(login(attaccoLogin, 'a caso') ?? null));
console.log('ricerca vulnerabile:', JSON.stringify(cercaVulnerabile(attaccoUnion)));
console.log('ricerca corretta :', JSON.stringify(cerca(attaccoUnion)));
console.log('ricerca normale :', JSON.stringify(cerca('Pen')));
// Output:
// login vulnerabile : {"id":1,"ruolo":"admin"}
// login corretto : null
// ricerca vulnerabile: [{"id":1,"nome":"chiave-di-prova-123"}]
// ricerca corretta : []
// ricerca normale : [{"id":2,"nome":"Penna"}]
Il primo attacco, admin@example.test' --, chiude la stringa dell'email e usa -- per trasformare in commento tutto il resto della query, compreso il controllo dell'hash: il login vulnerabile restituisce l'amministratore senza conoscere la password. Il secondo, x' UNION SELECT id, nota FROM segreti --, incolla alla ricerca una seconda SELECT con lo stesso numero di colonne e fa comparire nei risultati un contenuto di un'altra tabella. Nelle versioni corrette entrambi i testi sono solo stringhe: il login non trova nessuno (null), la ricerca non trova nessun prodotto, e una ricerca normale (Pen) funziona come prima.
Perché i parametri risolvono
Nelle funzioni corrette lo script scrive WHERE email = ? AND hash = ? e passa i valori a get(email, sha(password)). Il motore compila prima la query, con la sua struttura fissa, e poi lega i valori ai segnaposto: ciò che si lega è sempre un valore, mai sintassi. La documentazione di SQLite lo dice così: nel testo della query i letterali possono essere sostituiti da un parametro. Il perimetro è già nella frase: i parametri sostituiscono letterali, non nomi di tabelle o di colonne. La guida OWASP mette questa difesa al primo posto, e scoraggia in modo esplicito l'escaping manuale, perché è facile sbagliarlo. Chiarito il perimetro, restano tre casi che mettono in crisi chi crede che «uso i parametri» chiuda la questione.
Caso 1: il dato salvato e poi riusato (second order)
Nell'iniezione di secondo ordine il valore malevolo entra nel database in modo corretto, con un parametro, ed esplode più tardi in un'altra funzione che si fida di ciò che legge dal database. Lo script registra un cliente di nome x' OR '1'='1, lo rilegge dalla tabella e lo usa in una somma degli ordini, prima con la concatenazione e poi con il parametro.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE clienti (id INTEGER PRIMARY KEY, nome TEXT);
CREATE TABLE ordini (id INTEGER PRIMARY KEY, cliente TEXT, totale REAL);
INSERT INTO ordini VALUES (1, 'Anna', 10), (2, 'Bruno', 20), (3, 'Carla', 30);
`);
// Passo 1: la registrazione è fatta bene, con parametri. Il valore malevolo viene salvato così com'è.
db.prepare('INSERT INTO clienti (nome) VALUES (?)').run("x' OR '1'='1");
const salvato = db.prepare('SELECT nome FROM clienti WHERE id = 1').get().nome;
console.log('nome salvato nel database:', salvato);
// Passo 2: un'altra funzione si fida del database e concatena.
const totaleVulnerabile = (nome) =>
db.prepare(`SELECT SUM(totale) AS t FROM ordini WHERE cliente = '${nome}'`).get().t;
const totale = (nome) =>
db.prepare('SELECT SUM(totale) AS t FROM ordini WHERE cliente = ?').get(nome).t;
console.log('somma vulnerabile:', totaleVulnerabile(salvato));
console.log('somma corretta :', totale(salvato));
console.log('somma di Anna :', totale('Anna'));
// Output:
// nome salvato nel database: x' OR '1'='1
// somma vulnerabile: 60
// somma corretta : null
// somma di Anna : 10
Il nome viene salvato identico, perché il primo passo è sicuro; la somma vulnerabile restituisce 60, cioè la somma di tutti gli ordini, mentre quella corretta restituisce null (nessun cliente con quel nome) e quella di Anna, 10. La regola che ne discende è che non esistono «dati fidati» in generale: il database è una sorgente di dati non fidati quanto la richiesta HTTP, e la guida OWASP lo elenca fra le sorgenti. Si parametrizza ogni query, indipendentemente da dove arriva il valore.
Caso 2: nomi di colonne, ordinamenti e LIKE
Se la colonna di ordinamento la sceglie l'utente (?ordina=prezzo), un parametro non serve: il motore lo tratta come una costante e non ordina per niente. Lo script lo mostra, poi mostra che cosa succede concatenando il nome (un'espressione CASE che decide l'ordine in base a una tabella segreta: il risultato cambia in funzione di dati che l'utente non dovrebbe vedere) e infine la soluzione prevista da OWASP per le parti strutturali: una allowlist di valori fissi, posseduti dall'applicazione. Lo stesso blocco mostra un limite diverso: con LIKE il parametro evita l'iniezione, ma % e _ restano caratteri speciali del pattern.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE prodotti (id INTEGER PRIMARY KEY, nome TEXT, prezzo REAL);
CREATE TABLE segreti (id INTEGER PRIMARY KEY, nota TEXT);
INSERT INTO prodotti VALUES (1, 'Quaderno', 3.5), (2, 'Penna', 1.2), (3, 'Zaino', 25);
INSERT INTO segreti VALUES (1, 'riservato');
`);
const nomi = (righe) => righe.map((r) => r.nome).join(',');
// 1. Un parametro non può essere un nome di colonna: viene trattato come costante.
const conParametro = db.prepare('SELECT nome FROM prodotti ORDER BY ?').all('prezzo');
console.log('ORDER BY ? con "prezzo":', nomi(conParametro), '(ordine di inserimento: nessun ordinamento)');
// 2. Concatenare il nome della colonna riapre l'iniezione: qui un'espressione sceglie l'ordine
// in base a un dato di un'altra tabella (tecnica "blind", nessun dato esce direttamente).
const sort = "(CASE WHEN (SELECT count(*) FROM segreti) > 0 THEN prezzo ELSE nome END)";
console.log('ORDER BY concatenato :', nomi(db.prepare(`SELECT nome FROM prodotti ORDER BY ${sort}`).all()));
// 3. Soluzione: l'utente sceglie una chiave, l'applicazione possiede il testo SQL.
const COLONNE = new Map([['nome', 'nome'], ['prezzo', 'prezzo']]);
const DIREZIONI = new Map([['asc', 'ASC'], ['desc', 'DESC']]);
function elenca(colonna, direzione) {
const c = COLONNE.get(colonna);
const d = DIREZIONI.get(direzione);
if (!c || !d) throw new RangeError('ordinamento non ammesso');
return nomi(db.prepare(`SELECT nome FROM prodotti ORDER BY ${c} ${d}`).all());
}
console.log('allowlist prezzo desc :', elenca('prezzo', 'desc'));
try { elenca(sort, 'asc'); } catch (e) { console.log('allowlist con attacco :', e.name + ': ' + e.message); }
// 4. LIKE: il parametro evita l'iniezione, ma % e _ restano caratteri speciali del pattern.
const letterale = (t) => t.replace(/[\\%_]/g, (c) => '\\' + c);
console.log('LIKE con "%" :', nomi(db.prepare('SELECT nome FROM prodotti WHERE nome LIKE ?').all('%')));
console.log('LIKE con "%" escapato :', nomi(db.prepare("SELECT nome FROM prodotti WHERE nome LIKE ? ESCAPE '\\'").all(letterale('%'))) || '(nessuno)');
// Output:
// ORDER BY ? con "prezzo": Quaderno,Penna,Zaino (ordine di inserimento: nessun ordinamento)
// ORDER BY concatenato : Penna,Quaderno,Zaino
// allowlist prezzo desc : Zaino,Quaderno,Penna
// allowlist con attacco : RangeError: ordinamento non ammesso
// LIKE con "%" : Quaderno,Penna,Zaino
// LIKE con "%" escapato : (nessuno)
L'allowlist è una Map da chiave pubblica a testo SQL: l'utente sceglie prezzo e desc, ma l'unico testo che entra nella query è quello nella mappa; l'attacco ottiene un RangeError prima di toccare il database. Per LIKE, cercare % restituisce tutto; con la funzione letterale e la clausola ESCAPE la stessa ricerca non trova niente, perché ora cerca davvero il carattere percentuale. Non è una vulnerabilità di sicurezza in senso stretto, ma un errore di correttezza: la ricerca restituisce più di quanto l'utente voleva.
Caso 3: validare non è difendere
La validazione dell'input riduce ciò che arriva alla query e blocca molti abusi, ma non può essere la difesa contro l'iniezione. Il motivo è semplice: Dall'Orto è un cognome valido e contiene un apice, e se rifiuti gli apici rifiuti clienti veri. Il filtro che elimina i caratteri «pericolosi» è fragile e sbaglia in entrambe le direzioni. Quello che la validazione fa bene è far rispettare tipi e limiti. Lo script usa lo schema JSON di Fastify (validato con Ajv) su una rotta POST /ordini, eseguita con inject() senza aprire porte, e salva con un parametro.
import Fastify from 'fastify';
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE ordini (id INTEGER PRIMARY KEY, cliente TEXT, quantita INTEGER)');
const inserisci = db.prepare('INSERT INTO ordini (cliente, quantita) VALUES (?, ?)');
const app = Fastify();
app.post('/ordini', {
schema: {
body: {
type: 'object',
required: ['cliente', 'quantita'],
additionalProperties: false,
properties: {
cliente: { type: 'string', minLength: 1, maxLength: 60 },
quantita: { type: 'integer', minimum: 1, maximum: 100 },
},
},
},
}, async (req, reply) => {
const { cliente, quantita } = req.body;
inserisci.run(cliente, quantita);
return reply.code(201).send({ cliente, quantita });
});
const prove = [
['valido', { cliente: "Dall'Orto", quantita: 3 }],
['operatore NoSQL come testo', { cliente: { $ne: null }, quantita: 3 }],
['quantità fuori limite', { cliente: 'Anna', quantita: 5000 }],
['quantità testuale coerente', { cliente: 'Anna', quantita: '7' }],
['quantità non numerica', { cliente: 'Anna', quantita: 'molte' }],
['campo in più', { cliente: 'Anna', quantita: 1, ruolo: 'admin' }],
];
for (const [nome, payload] of prove) {
const r = await app.inject({ method: 'POST', url: '/ordini', payload });
console.log(`${nome}: ${r.statusCode} ${r.statusCode === 400 ? r.json().message : r.body}`);
}
console.log('righe salvate:', JSON.stringify(db.prepare('SELECT cliente, quantita, typeof(quantita) AS tipo FROM ordini').all()));
// Output:
// valido: 201 {"cliente":"Dall'Orto","quantita":3}
// operatore NoSQL come testo: 400 body/cliente must be string
// quantità fuori limite: 400 body/quantita must be <= 100
// quantità testuale coerente: 201 {"cliente":"Anna","quantita":7}
// quantità non numerica: 400 body/quantita must be integer
// campo in più: 201 {"cliente":"Anna","quantita":1}
// righe salvate: [{"cliente":"Dall'Orto","quantita":3,"tipo":"integer"},{"cliente":"Anna","quantita":7,"tipo":"integer"},{"cliente":"Anna","quantita":1,"tipo":"integer"}]
Il cognome con l'apice passa e si salva senza problemi, perché è un parametro. Un oggetto al posto del testo, come {"$ne": null}, viene respinto con 400: è l'operator injection descritta da PortSwigger per MongoDB, dove un client manda un operatore di query al posto di un valore. Qui il database è SQLite e non c'è niente di MongoDB: la prova mostra solo che lo schema ferma il tipo sbagliato prima che raggiunga qualunque query. Due comportamenti di Fastify vanno conosciuti perché sorprendono. Il primo è la coercizione: quantita: "7" viene accettato e salvato come intero 7 (nel database il tipo è integer), perché la configurazione predefinita di Ajv converte i tipi; la documentazione avverte di valutarla e mostra come disattivarla sul corpo. Il secondo: con additionalProperties: false il campo extra ruolo non viene rifiutato ma rimosso in silenzio, perché l'opzione removeAdditional vale true. Questo protegge dall'assegnazione di massa, ma l'errore sparisce; se vuoi rifiutare la richiesta, cambia la configurazione.
Gli strati che completano il quadro
Se un'iniezione riesce, il danno dipende dai privilegi dell'account del database. Per questo OWASP raccomanda il minimo privilegio: partire da ciò che serve all'applicazione invece di togliere ciò che avanza, dare sola lettura agli account che leggono soltanto, mai privilegi di amministratore, e usare viste per esporre solo parte di una tabella. Gli ORM non cambiano il discorso: ogni libreria che accetta SQL grezzo accetta anche la concatenazione, e il difetto non sta nello strumento ma nel punto in cui costruisci la stringa. Prima di una revisione puoi usare questa verifica:
- nessuna query costruita con template string o concatenazione che contenga valori di input, di qualunque sorgente;
- ogni nome di colonna, tabella o direzione scelto dall'utente passa per una allowlist;
- la validazione dichiara tipi, lunghezze e intervalli, e non prova a filtrare «caratteri pericolosi»;
- l'account usato dall'applicazione non possiede più diritti di quelli che il codice usa.
Le quattro voci non hanno un ordine di importanza fisso, ma la prima è la sola che chiude le iniezioni classiche: le altre limitano il perimetro e il danno. Il confronto con lo strato precedente della serie è utile: dove l'articolo 02 metteva il dato in un documento, qui il dato finisce in un interprete diverso, e il principio di tenerlo separato dalla sintassi è lo stesso.
Domande frequenti
Usare un ORM mi mette al sicuro dalla SQL injection?
Solo finché non costruisci query grezze concatenando valori. Le interrogazioni generate dall'ORM sono parametrizzate, ma ogni interfaccia che accetta testo SQL va trattata come una query scritta a mano. I nomi di colonna scelti dall'utente restano un problema anche con un ORM.
Posso difendermi filtrando gli apici e le parole come UNION?
No. Un filtro di questo tipo blocca clienti veri (Dall'Orto) e lascia passare le varianti che non ha previsto. OWASP indica le query parametrizzate come difesa principale e l'escaping manuale come scelta da evitare, perché facile da sbagliare.
La validazione dello schema serve ancora, allora?
Sì, per un altro scopo: fa rispettare tipi, lunghezze e intervalli, e ferma un oggetto dove ti aspetti un testo. Riduce la superficie e rende gli errori chiari, ma non sostituisce i parametri, che sono ciò che separa dato e sintassi.
Che cosa sai fare adesso
Sai riprodurre un bypass del login e una UNION su un database di prova, e correggerli con parametri. Sai spiegare perché un parametro non è un nome di colonna e come si sceglie un ordinamento con una allowlist, perché il dato letto dal database va trattato come non fidato, e come configurare Fastify sapendo che coerce i tipi e rimuove in silenzio i campi extra. Il passo successivo è l'autenticazione: ciò che qui era un login finto diventa un sistema di sessioni che regge.
Fonti
- OWASP SQL Injection Prevention Cheat Sheet - definizione, difese primarie, allowlist per le parti strutturali, minimo privilegio, consultata il 2026-11-13.
- SQLite: Binding Values To Prepared Statements - i letterali possono essere sostituiti da parametri, consultata il 2026-11-13.
- Node.js v24: SQLite - modulo
node:sqlite, stabilità «1.2 release candidate», consultata il 2026-11-13. - Fastify: Validation and Serialization - configurazione predefinita di Ajv (
coerceTypes,removeAdditional), consultata il 2026-11-13. - PortSwigger Web Security Academy: NoSQL injection - operator injection, consultata il 2026-11-13.
- CISA AA23-158A: CL0P Ransomware Gang Exploits CVE-2023-34362 MOVEit Vulnerability - cronologia e natura della vulnerabilità, consultata il 2026-11-13.
- OWASP Top 10:2025, A05 Injection - collocazione di SQL injection e XSS, consultata il 2026-11-13.
- Gli script sono stati eseguiti con
node24.11.0 (--disable-warning=ExperimentalWarningpernode:sqlite) e Fastify 5.12.8, due volte, con uscita identica. Nessuna porta aperta.

Commenti
Caricamento commenti...
Accedi per commentare