Prevenire la SQL injection in Node.js, Python e Go
Snippet SQL vulnerabili e corretti per pg, Prisma, psycopg, SQLAlchemy e database/sql di Go, più ORDER BY dinamici e liste IN sicure. Mappati su CWE-89.
· 7 min di lettura · Lina Source LLC
La SQL injection (CWE-89) è uno dei bug più antichi del web e ancora uno dei più dannosi. La causa non è mai cambiata: l'input dell'utente viene incollato nel testo di una query, quindi il database non riesce a distinguere i dati dal codice. Nemmeno la correzione è cambiata: invia la query e i valori separatamente, e lascia che sia il driver a fare il binding.
Ciò che è cambiato è dove vive il bug. La maggior parte dei team usa un ORM per le query quotidiane, quindi oggi l'injection compare nelle scappatoie: la query raw scritta per un report, l'endpoint di ricerca con un ordinamento dinamico, lo script di migrazione, il tool di amministrazione. Questa guida percorre la versione vulnerabile e quella corretta in tre ecosistemi, poi affronta i due casi che i parametri da soli non risolvono.
L'impatto è raramente limitato a una sola tabella. Una singola query iniettabile gira di solito con tutti i privilegi che l'applicazione ha sul database, quindi può leggere i dati di ogni cliente, compresi hash delle password e token API, modificare righe e, su alcuni database, raggiungere il file system o altri server. Le tecniche blind estraggono i dati un bit alla volta anche quando il risultato della query non viene mai mostrato all'utente, quindi un endpoint che restituisce solo vero o falso resta comunque sfruttabile.
Perché i parametri funzionano e l'escaping no
Con una query parametrizzata, il driver invia il testo SQL con dei segnaposto e i valori viaggiano come dati separati. Il database analizza l'istruzione prima ancora di vedere il valore, quindi un apice o un punto e virgola nell'input è solo un carattere in una stringa. L'escaping manuale cerca di rendere un valore sicuro da incollare nel testo SQL, e fallisce su codifiche, contesti numerici, identificatori e su ogni caso limite a cui non hai pensato. Non fare escaping: fai il binding.
Node.js: pg e Prisma
Con node-postgres, lo schema vulnerabile è un template literal passato a query. La correzione è un segnaposto $1 e un array di valori. Prisma è sicuro per impostazione predefinita, ma ha due API raw e solo una delle due è sicura con l'interpolazione.
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// Vulnerabile: l'input diventa parte del testo SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// Corretto: il valore viene passato come parametro
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma: $queryRaw è un tagged template, le interpolazioni diventano parametri
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma: $queryRawUnsafe con concatenazione è iniettabile
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// Se devi usare $queryRawUnsafe, passa i valori come argomenti
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);Una trappola sottile di Prisma: costruire prima la stringa della query e passarla a $queryRaw fa perdere la protezione. La sicurezza deriva dalla sintassi del tagged template. Se devi comporre frammenti, usa Prisma.sql e Prisma.join, che mantengono i valori come parametri. La stessa regola vale per altre librerie basate su tagged template come postgres.js e slonik: è il tag a rendere sicura l'interpolazione, quindi una stringa di query assemblata prima e passata come semplice valore lo aggira.
Python: psycopg e SQLAlchemy
In Python gli strumenti pericolosi sono le f-string, l'operatore % e str.format applicati all'SQL. psycopg usa segnaposto %s, che sembrano formattazione di stringhe ma non lo sono: i valori vanno nel secondo argomento di execute, mai attraverso l'operatore %. Il costrutto text() di SQLAlchemy è sicuro quando usi parametri di bind con nome e insicuro quando formatti i valori dentro la stringa.
Un dettaglio di psycopg coglie molti di sorpresa: il segnaposto è %s (o %(name)s per i valori con nome) indipendentemente dal tipo della colonna, e non si aggiungono mai apici attorno. Scrivere '%s' con gli apici riporta il parametro dentro una stringa letterale e rompe la query. Lo stesso vale per i parametri con nome in SQLAlchemy: :status, non ':status'.
from sqlalchemy import text
# psycopg: vulnerabile
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg: corretto, valori passati separatamente
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text(): vulnerabile
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text(): corretto con un parametro di bind
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go: database/sql
Il pacchetto database/sql di Go supporta i segnaposto in modo nativo, ma la loro sintassi dipende dal driver: $1 per i driver PostgreSQL come pgx, ? per MySQL e SQLite. Lo schema vulnerabile è fmt.Sprintf o la concatenazione di stringhe per costruire la clausola WHERE. Poiché Go è tipizzato staticamente, viene da pensare che un parametro int non possa essere pericoloso. Questo vale per il valore in sé, ma l'abitudine di costruire query con Sprintf si estende ai parametri stringa accanto a esso, quindi mantieni ogni query sui segnaposto.
// Vulnerabile: concatenazione di stringhe
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// Corretto: segnaposto più argomento (sintassi PostgreSQL; MySQL usa ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// non trovato
}Le scappatoie degli ORM
Gli ORM proteggono il query builder, non ogni metodo dell'ORM. Questi sono i punti da controllare per primi in qualsiasi codebase:
- Prisma: $queryRawUnsafe ed $executeRawUnsafe, e qualsiasi chiamata a $queryRaw che riceve una stringa precostruita anziché un tagged template.
- Sequelize e TypeORM: sequelize.query, chiamate .where() del query builder a cui viene passata una stringa concatenata e clausole order o group raw.
- Knex: knex.raw e whereRaw con valori interpolati anziché binding con ?.
- SQLAlchemy: text() con f-string, e literal_column() o nomi di colonna presi dall'input.
- Django: .raw(), .extra() e cursor.execute con stringhe formattate.
- GORM: Where, Order e Raw chiamati con l'output di fmt.Sprintf anziché con argomenti.
ORDER BY dinamico: i parametri non possono aiutare
I segnaposto fanno il binding dei valori, non degli identificatori. Non puoi scrivere ORDER BY $1 e passare il nome di una colonna; il database ordinerebbe per una costante. Così una tabella ordinabile tende a riportare gli sviluppatori alla costruzione di stringhe, ed è lì che l'injection ritorna. La stessa limitazione vale per i nomi di tabella, i nomi di schema e le parole chiave SQL come ASC e DESC.
Lo schema sicuro è una allowlist che mappa le chiavi di ordinamento esposte all'utente su nomi di colonna noti. L'input seleziona una voce; non diventa mai SQL. La direzione riceve lo stesso trattamento: mappala su un ASC o un DESC fissi.
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// Sicuro: col e dir possono valere solo quanto definito nel codice sopra
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)Se hai davvero bisogno di un identificatore dinamico che non può essere gestito con una allowlist, usa il quoting degli identificatori del driver anziché il tuo. In psycopg è sql.SQL(...).format(sql.Identifier(name)); in pgx è pgx.Identifier{name}.Sanitize(). Una allowlist resta comunque preferibile, perché il quoting rende il nome sicuro ma non necessariamente valido o consentito. Un identificatore quotato permette comunque a chi chiama di ordinare o filtrare per qualsiasi colonna della tabella, comprese quelle che non intendevi esporre, come un hash di password o un punteggio interno. Ordinare per una colonna nascosta può far trapelare i suoi valori attraverso l'ordine dei risultati, un confronto alla volta.
Liste IN senza costruire stringhe
Filtrare per una lista di ID è l'altra scusa comune per la concatenazione. Ogni stack importante ha un modo sicuro di farlo:
- PostgreSQL con pg o pgx: passa l'intera lista come un unico parametro array e scrivi WHERE id = ANY($1). Il driver la invia come array Postgres.
- Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` si espande in un segnaposto per elemento.
- SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), poi passa una lista.
- Go con MySQL: genera la stringa dei segnaposto a partire dalla lunghezza della lista (strings.Repeat("?,", n) ripulito) e passa i valori come argomenti. Vengono generati solo i segnaposto, mai i valori.
- Rifiuta le liste vuote prima che la query venga eseguita e limita la dimensione della lista, così l'endpoint non può essere usato per costruire istruzioni enormi.
Difesa in profondità
La parametrizzazione è la correzione. Questi accorgimenti riducono il raggio d'azione quando una query sfugge:
- Connettiti con un ruolo di database che abbia solo i privilegi necessari all'applicazione. Un'applicazione web raramente ha bisogno di DROP, ALTER o dell'accesso ad altri schemi.
- Non restituire ai client gli errori grezzi del database. L'injection basata sugli errori si regge sul poterli vedere.
- Valida i tipi al confine: un ID che dovrebbe essere un intero o un UUID va interpretato come tale prima di raggiungere il data layer.
- Imposta un timeout sulle istruzioni, che limita sia l'injection blind basata sui tempi sia le query fuori controllo.
Trovare quelle che hai già
Parti da una ricerca delle scappatoie elencate sopra, poi delle parole chiave SQL vicino alla formattazione di stringhe: SELECT o WHERE dentro template literal, f-string, Sprintf o concatenazione con +. Ogni risultato è un caso in cui il binding è corretto, un caso costruito da una allowlist oppure un bug. Non saltare i percorsi di codice che sembrano interni: importatori CSV, cron job e dashboard di amministrazione ricevono spesso input che in origine proveniva da un utente, solo a un passo di distanza. I valori riletti dal tuo stesso database possono trasportare un payload salvato in modo sicuro in precedenza e poi incollato in una query più tardi: è la cosiddetta injection di secondo ordine. CodeAuditAgent esegue questo tracciamento tra i file per repository GitHub pubblici e codice incollato, e segnala i problemi CWE-89 con la query citata, il modo in cui l'input la raggiunge e una versione corretta.
La regola da portarsi via è breve: i valori vanno nei parametri, gli identificatori arrivano da una allowlist e niente che provenga dalla richiesta viene mai incollato nel testo SQL.