Un singolo apice mal gestito può esporre l’intero database di produzione. SQL injection rimane nel 2026 la seconda vulnerabilità per numero di CVE: CWE-89 ha prodotto 3.349 CVE nel solo 2025, con una crescita del 75% rispetto all’anno precedente. Compare come A05 nell’OWASP Top 10 2025, e secondo SecureLayer7 colpisce il 100% delle applicazioni web testate. Questa guida mostra come costruire un’API Node.js resistente a ogni variante di SQL injection, dal classico attacco UNION alle tecniche blind e time-based, usando query parametrizzate, ORM sicuri e difese in profondità.
Cos’è SQL Injection e Perché Conta Ancora nel 2026
SQL injection (SQLi) si verifica quando dati non validati forniti dall’utente vengono concatenati direttamente in una query SQL, permettendo all’attaccante di modificare la logica della query stessa. L’esempio più noto è il login bypass: se l’applicazione costruisce la query come SELECT * FROM users WHERE username='${input}', passare ' OR '1'='1 come username produce una condizione sempre vera, bypassando l’autenticazione senza conoscere alcuna password.
Secondo il Verizon Data Breach Investigations Report 2026, l’exploitation di vulnerabilità ha causato il 31% di tutte le violazioni, rispetto al 20% del 2025. CWE-89 (SQL Injection) è la categoria di vulnerabilità con il maggior volume di CVE nel 2025. Il costo medio globale di una violazione dati è salito a 4,44 milioni di dollari nel 2025 (IBM Cost of a Data Breach Report 2025), mentre il mercato statunitense tocca i 10,22 milioni di dollari, un record storico. Il tempo medio di remediation per le vulnerabilità di injection supera i 13 mesi per raggiungere il 50% di risoluzione, il dato peggiore tra tutte le categorie OWASP secondo il DBIR 2026.
Le varianti di SQL injection rilevanti per Node.js sono cinque. La classica (in-band), dove il risultato appare direttamente nella risposta HTTP. La UNION-based, che sfrutta l’operatore UNION per estrarre dati da altre tabelle. La blind boolean-based, dove l’attaccante deduce informazioni dalla differenza nel comportamento dell’app (risposta vera vs. falsa). La time-based, che usa funzioni come SLEEP() o pg_sleep() per inferire dati dai ritardi di risposta. E l’out-of-band, che sfrutta DNS o HTTP per esfiltrare dati verso un server controllato dall’attaccante.
| Variante SQLi | Tecnica | Rilevabilità | Danno tipico |
|---|---|---|---|
| In-band classica | Apice + iniezione diretta | Alta (errori visibili) | Dump completo del DB |
| UNION-based | UNION SELECT su colonne aggiuntive | Media | Lettura di qualsiasi tabella |
| Blind boolean | Condizioni vero/falso | Bassa | Estrazione lenta dei dati |
| Time-based blind | SLEEP() / pg_sleep() | Molto bassa | Estrazione via timing |
| Out-of-band | DNS/HTTP exfiltration | Quasi nulla | Esfiltrazione silenziosa |
Prerequisiti
Per seguire questa guida servono:
- Node.js 22.x LTS o superiore (versione consigliata per le patch CVE-2026-21710 e CVE-2026-21715 che toccano il runtime)
- npm 10.x o superiore
- PostgreSQL 16+ oppure MySQL 8.4+ oppure SQLite 3.45+ (per i test locali)
- Conoscenza base di Express.js e async/await in Node.js
- Accesso a un terminale Unix o PowerShell su Windows
Le librerie usate in questa guida e le loro versioni correnti a giugno 2026:
| Libreria | Versione | Database supportati | Tipo |
|---|---|---|---|
| pg (node-postgres) | 8.x | PostgreSQL | Driver nativo |
| mysql2 | 3.x | MySQL, MariaDB | Driver nativo |
| Prisma | 6.x | Postgres, MySQL, SQLite, MSSQL | ORM type-safe |
| Sequelize | 6.x | Postgres, MySQL, SQLite, MSSQL | ORM classico |
| Knex.js | 3.x | Postgres, MySQL, SQLite, MSSQL | Query builder |
| zod | 3.x | n/a | Validazione schema |
| helmet | 8.x | n/a | Header HTTP sicuri |
Step 1: Inizializzare il Progetto Node.js Sicuro
Crea la struttura base del progetto. Useremo PostgreSQL come database principale, ma i principi si applicano identici a MySQL e SQLite. L’intera struttura serve come punto di partenza per una API Express production-ready, con middleware di sicurezza attivi fin dall’inizio.
mkdir sqli-safe-api && cd sqli-safe-api
npm init -y
# Driver database (installa solo quello che usi)
npm install pg # PostgreSQL
npm install mysql2 # MySQL / MariaDB
# ORM e query builder (scegli uno)
npm install @prisma/client prisma # Prisma ORM
npm install sequelize # Sequelize ORM
npm install knex # Knex.js query builder
# Framework, validazione e sicurezza
npm install express zod helmet express-rate-limit
# Dev tools
npm install -D nodemon
Crea il file app.js con la struttura Express di base e i middleware di sicurezza attivati dall’inizio:
// app.js
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
// Helmet imposta automaticamente 11 header HTTP di sicurezza:
// CSP, HSTS, X-Frame-Options, X-Content-Type-Options, ecc.
app.use(helmet());
// Limita il body JSON a 10 KB: previene attacchi di body stuffing
app.use(express.json({ limit: '10kb' }));
// Rate limiting: max 100 richieste ogni 15 minuti per IP
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Troppe richieste, riprova tra 15 minuti' },
});
app.use('/api/', apiLimiter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server avviato sulla porta ${PORT}`));
module.exports = app;
Step 2: Il Codice Vulnerabile (da Non Usare Mai)
Prima di mostrare la soluzione corretta, è necessario capire il pattern vulnerabile che appare ancora in migliaia di repository Node.js su GitHub. Questo codice è intenzionalmente insicuro: non usarlo mai in produzione, nemmeno come punto di partenza da “aggiustare dopo”.
// CODICE VULNERABILE - NON USARE IN PRODUZIONE
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get('/api/users/search', async (req, res) => {
const { username } = req.query;
// ERRORE CRITICO: concatenazione diretta dell'input nella query SQL
const query = `SELECT id, email, role FROM users WHERE username = '${username}'`;
try {
const result = await pool.query(query);
res.json(result.rows);
} catch (err) {
// SECONDO ERRORE: espone stack trace e testo della query all'attaccante
res.status(500).json({ error: err.message, query: query });
}
});
Con questo codice, un attaccante può passare ' OR '1'='1' -- come username e ottenere tutti gli utenti del database. Con ' UNION SELECT table_name, null, null FROM information_schema.tables -- può elencare tutte le tabelle. La variante time-based ' OR pg_sleep(5) -- conferma la vulnerabilità tramite il ritardo di risposta senza esporre alcun errore visibile.
Attenzione anche all’anti-pattern del filtraggio manuale con blacklist di caratteri. Rimuovere semplicemente gli apici non è sufficiente: esistono bypass tramite encoding esadecimale (0x61646d696e), bypass con doppio encoding, e tecniche che non usano affatto gli apici (injection su colonne numeriche come 1 OR 1=1). L’unica difesa affidabile è la query parametrizzata.
Step 3: Query Parametrizzate con pg (PostgreSQL)
Il metodo corretto usa le query parametrizzate (dette anche prepared statements): i valori forniti dall’utente vengono inviati al database come parametri tipizzati, separati dalla struttura della query SQL. Il database non li interpreta mai come codice SQL, indipendentemente dal contenuto.
// drivers/postgres.js
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: false,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: 30000, // Blocca query che durano più di 30s
query_timeout: 30000,
});
module.exports = pool;
// routes/users.js
const pool = require('../drivers/postgres');
// SICURO: query parametrizzata - $1 è un placeholder tipizzato
app.get('/api/users/search', async (req, res, next) => {
try {
const { username } = req.query;
// Il valore di username viene inviato al DB su un canale separato
// Anche se contiene ' OR '1'='1, il DB la tratta come stringa letterale
const result = await pool.query(
'SELECT id, email, role FROM users WHERE username = $1',
[username]
);
res.json(result.rows);
} catch (err) {
next(err); // Passa all'error handler centrale, non espone err.message
}
});
// Query con più parametri: $1, $2, $3... in ordine
app.post('/api/users', async (req, res, next) => {
try {
const { username, email, role } = req.body;
const result = await pool.query(
`INSERT INTO users (username, email, role, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, username, email, role`,
[username, email, role]
);
res.status(201).json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Prepared statement esplicito: il piano di esecuzione viene calcolato una volta sola
// e riusato per tutte le chiamate successive (vantaggio prestazionale + sicurezza)
async function getUserById(id) {
return pool.query({
name: 'get-user-by-id',
text: 'SELECT id, username, email, role FROM users WHERE id = $1',
values: [parseInt(id, 10)],
});
}
Con pg, ogni query parametrizzata invia al server PostgreSQL un messaggio “Parse” con la query template e un messaggio “Bind” con i valori. Il driver garantisce che i valori non vengano mai interpolati nella stringa SQL. Per query eseguite frequentemente, i named prepared statement offrono anche un vantaggio prestazionale: il piano di esecuzione viene calcolato una sola volta e riusato per tutte le chiamate successive della stessa connessione.
Step 4: Query Parametrizzate con mysql2 (MySQL e MariaDB)
Per MySQL e MariaDB, mysql2 usa il placeholder ? invece di $1. C’è però una differenza critica rispetto a pg: con mysql2 esistono due metodi con comportamenti di sicurezza diversi.
// drivers/mysql.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
// IMPORTANTE: disabilita le query multi-statement
// Previene attacchi tipo '; DROP TABLE users; --'
multipleStatements: false,
// SSL in produzione
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: undefined,
connectionLimit: 10,
waitForConnections: true,
queueLimit: 0,
});
module.exports = pool;
// CRITICO: usa .execute() non .query() per le prepared statement
// .execute() -> prepared statement veri (sicuri)
// .query() -> client-side interpolation (meno sicuro)
// SICURO: execute() con prepared statement
app.get('/api/products', async (req, res, next) => {
try {
const { category, maxPrice } = req.query;
// I ? vengono sostituiti in ordine dall'array di valori
const [rows] = await pool.execute(
'SELECT id, name, price, stock FROM products WHERE category = ? AND price <= ? AND active = TRUE',
[category, Number(maxPrice)]
);
res.json(rows);
} catch (err) {
next(err);
}
});
// Query con IN(): mysql2 espande gli array in placeholder multipli
app.get('/api/products/batch', async (req, res, next) => {
try {
const ids = req.query.ids?.split(',').map(Number).filter(n => !isNaN(n)) || [];
if (!ids.length) return res.status(400).json({ error: 'IDs richiesti' });
// Genera i placeholder dinamicamente in modo sicuro
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT id, name, price FROM products WHERE id IN (${placeholders})`,
ids
);
res.json(rows);
} catch (err) {
next(err);
}
});
Step 5: SQL Injection Prevention con Prisma ORM
Prisma è il più popolare ORM type-safe per Node.js nel 2026. Tutte le query generate da Prisma Client attraverso le sue API standard usano automaticamente query parametrizzate: non c’è modo di iniettare SQL tramite i metodi dell’ORM. Il rischio rimane solo con le raw query, che richiedono attenzione esplicita.
// prisma/schema.prisma (schema di esempio)
//
// generator client {
// provider = "prisma-client-js"
// }
// datasource db {
// provider = "postgresql"
// url = env("DATABASE_URL")
// }
// model User {
// id Int @id @default(autoincrement())
// username String @unique @db.VarChar(50)
// email String @unique @db.VarChar(255)
// role String @default("user") @db.VarChar(20)
// }
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: ['warn', 'error'], // Non loggare le query in produzione (privacy)
});
// SICURO: metodi ORM di Prisma - sempre parametrizzati automaticamente
app.get('/api/users/search', async (req, res, next) => {
try {
const { username, role, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit);
const users = await prisma.user.findMany({
where: {
AND: [
username ? { username: { contains: username, mode: 'insensitive' } } : {},
role ? { role: role } : {},
],
},
select: { id: true, username: true, email: true, role: true },
take: Math.min(Number(limit), 50), // Limite massimo: 50
skip: offset,
orderBy: { id: 'asc' },
});
res.json({ data: users, page: Number(page) });
} catch (err) {
next(err);
}
});
// RAW QUERY con Prisma: usa SEMPRE i tagged template literals (sicuri)
// Prisma estrae automaticamente le variabili come parametri separati
app.get('/api/users/:id/stats', async (req, res, next) => {
try {
const userId = parseInt(req.params.id, 10);
if (isNaN(userId) || userId < 1) {
return res.status(400).json({ error: 'ID utente non valido' });
}
// SICURO: template literal - ${userId} diventa un parametro, non sintassi SQL
const stats = await prisma.$queryRaw`
SELECT u.id, u.username, COUNT(o.id)::int AS order_count,
SUM(o.total)::numeric AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = ${userId}
GROUP BY u.id, u.username
`;
res.json(stats[0] ?? null);
} catch (err) {
next(err);
}
});
Il punto critico con Prisma è la distinzione tra le raw query sicure e quelle pericolose. prisma.$queryRaw con tagged template literal è sicuro perché Prisma estrae i valori interpolati e li invia come parametri. Al contrario, prisma.$queryRawUnsafe(string) è pericoloso quanto la concatenazione manuale e va usato solo con stringhe completamente statiche, mai con input utente.
Step 6: Query Sicure con Sequelize e Knex.js
Sequelize e Knex.js offrono API diverse ma lo stesso livello di protezione quando usati correttamente. Entrambi parametrizzano automaticamente i valori passati attraverso le loro API standard, con un'area di rischio specifica nelle raw query.
// === SEQUELIZE ===
const { Sequelize, DataTypes, Op } = require('sequelize');
const sequelize = new Sequelize(process.env.DATABASE_URL, {
logging: false,
dialectOptions: {
ssl: process.env.NODE_ENV === 'production'
? { require: true, rejectUnauthorized: true }
: false,
},
});
// Metodi ORM di Sequelize: parametrizzati automaticamente
app.get('/api/users', async (req, res, next) => {
try {
const { search, role } = req.query;
const users = await User.findAll({
where: {
...(search ? { username: { [Op.iLike]: `%${search}%` } } : {}),
...(role ? { role: role } : {}),
},
attributes: ['id', 'username', 'email', 'role'],
limit: 20,
});
res.json(users);
} catch (err) { next(err); }
});
// Raw query con Sequelize: usa SEMPRE replacements, non interpolazione
app.get('/api/reports/by-role', async (req, res, next) => {
try {
const { role } = req.query;
const [results] = await sequelize.query(
'SELECT role, COUNT(*) AS total FROM users WHERE role = :role GROUP BY role',
{
replacements: { role }, // Sicuro: parametro named
type: Sequelize.QueryTypes.SELECT,
}
);
res.json(results);
} catch (err) { next(err); }
});
// === KNEX.JS ===
const knex = require('knex')({
client: 'pg',
connection: process.env.DATABASE_URL,
pool: { min: 2, max: 10 },
});
// Query builder di Knex: parametrizzato automaticamente
app.get('/api/products', async (req, res, next) => {
try {
const { category, minPrice, maxPrice, sort = 'price', order = 'asc' } = req.query;
// Whitelist per ordinamento dinamico (nomi colonne non parametrizzabili)
const ALLOWED_COLS = new Set(['id', 'name', 'price', 'created_at']);
const ALLOWED_ORDERS = new Set(['asc', 'desc']);
const safeSort = ALLOWED_COLS.has(sort) ? sort : 'price';
const safeOrder = ALLOWED_ORDERS.has(order) ? order : 'asc';
const products = await knex('products')
.select('id', 'name', 'price', 'stock', 'category')
.where('category', category)
.whereBetween('price', [Number(minPrice) || 0, Number(maxPrice) || 999999])
.where('active', true)
.orderBy(safeSort, safeOrder)
.limit(100);
res.json(products);
} catch (err) { next(err); }
});
// Raw con Knex: usa knex.raw() con binding espliciti
app.get('/api/products/featured', async (req, res, next) => {
try {
const { category } = req.query;
const result = await knex.raw(
'SELECT id, name, price FROM products WHERE category = ? AND featured = TRUE LIMIT 10',
[category] // Array di binding: sicuro
);
res.json(result.rows);
} catch (err) { next(err); }
});
Step 7: Validazione dell'Input con Zod
Le query parametrizzate proteggono dalla SQL injection. La validazione dell'input è una difesa in profondità che blocca l'attacco ancora prima che raggiunga il database. Con Zod puoi definire schemi rigorosi per ogni endpoint e rifiutare input malformati con messaggi di errore chiari, senza esporre dettagli interni.
const { z } = require('zod');
// Schema per ricerca utenti
const UserSearchSchema = z.object({
q: z.string().max(100)
.regex(/^[\w\s@._-]+$/, 'Caratteri non consentiti')
.optional(),
role: z.enum(['admin', 'user', 'moderator']).optional(),
page: z.coerce.number().int().min(1).max(100).default(1),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
// Schema per creazione utente
const CreateUserSchema = z.object({
username: z.string().min(3).max(50)
.regex(/^[a-zA-Z0-9_]+$/, 'Solo lettere, numeri e underscore'),
email: z.string().email().max(255).transform(v => v.toLowerCase()),
password: z.string().min(12).max(128),
role: z.enum(['user', 'moderator']).default('user'),
});
// Middleware di validazione riusabile
function validate(schema, source = 'query') {
return (req, res, next) => {
const data = source === 'body' ? req.body : req.query;
const result = schema.safeParse(data);
if (!result.success) {
return res.status(400).json({
error: 'Dati non validi',
issues: result.error.flatten().fieldErrors,
});
}
req.validated = result.data;
next();
};
}
// Applicazione: validazione + query parametrizzata
app.get('/api/users',
validate(UserSearchSchema, 'query'),
async (req, res, next) => {
try {
const { q, role, page, limit } = req.validated;
const offset = (page - 1) * limit;
// Query con LIKE sicuro: il wildcard % è nel valore (non nella query)
const result = await pool.query(
`SELECT id, username, email, role
FROM users
WHERE ($1::text IS NULL OR username ILIKE '%' || $1 || '%')
AND ($2::text IS NULL OR role = $2)
ORDER BY id ASC
LIMIT $3 OFFSET $4`,
[q ?? null, role ?? null, limit, offset]
);
res.json({ data: result.rows, page, limit });
} catch (err) {
next(err);
}
}
);
Step 8: Gestione Sicura di Nomi di Colonne e Tabelle Dinamici
Le query parametrizzate proteggono i valori, non i nomi di colonne o tabelle. Se l'applicazione costruisce query con nomi di colonne dinamici (ad esempio per permettere all'utente di scegliere il campo di ordinamento), devi usare una whitelist esplicita. Questo è uno dei punti dove molti sviluppatori cadono in un falso senso di sicurezza.
// PATTERN PERICOLOSO: ordinamento dinamico senza whitelist
// VULNERABILE a: GET /api/users?sort=1;SELECT+pg_sleep(5)--
// NON FARE: `ORDER BY ${req.query.sort}` nemmeno con pg
// PATTERN SICURO: whitelist esplicita di colonne e ordini consentiti
const ALLOWED_SORT_COLUMNS = new Set([
'id', 'username', 'email', 'created_at', 'updated_at', 'role'
]);
const ALLOWED_SORT_ORDERS = new Set(['ASC', 'DESC']);
function parseSafeOrderBy(column, order) {
const safeCol = ALLOWED_SORT_COLUMNS.has(column)
? column
: 'id';
const safeOrder = ALLOWED_SORT_ORDERS.has(order?.toUpperCase())
? order.toUpperCase()
: 'ASC';
return { safeCol, safeOrder };
}
app.get('/api/users/sorted', async (req, res, next) => {
try {
const { sort = 'id', order = 'ASC', search } = req.query;
const { safeCol, safeOrder } = parseSafeOrderBy(sort, order);
// I nomi colonna provengono dalla whitelist (costanti, non input utente)
// Il valore di search è un parametro ($1)
const query = `
SELECT id, username, email, role, created_at
FROM users
WHERE ($1::text IS NULL OR username ILIKE '%' || $1 || '%')
ORDER BY ${safeCol} ${safeOrder}
LIMIT 50
`;
const result = await pool.query(query, [search ?? null]);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Per nomi di tabelle dinamici: stessa logica con whitelist
const ALLOWED_TABLES = new Set(['users', 'products', 'orders', 'categories']);
function safeTable(name) {
if (!ALLOWED_TABLES.has(name)) {
throw Object.assign(new Error('Tabella non consentita'), { statusCode: 400 });
}
return name; // Safe: proveniente dalla whitelist
}
Step 9: Configurazione Sicura del Database
La difesa in profondità richiede anche una configurazione del database che limiti il danno in caso di compromissione parziale. Il principio del privilegio minimo (least privilege) riduce drasticamente l'impatto di un attacco riuscito: anche se un attaccante ottiene l'accesso tramite una vulnerabilità non ancora patchata, i danni che può fare sono circoscritti ai soli privilegi dell'utente applicativo.
-- PostgreSQL: crea un utente applicazione con soli privilegi necessari
-- Esegui come superuser (DBA)
CREATE ROLE app_user WITH LOGIN PASSWORD 'usa_un_secret_manager_non_questa_stringa';
GRANT CONNECT ON DATABASE myapp_db TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
-- Solo le operazioni necessarie per le tabelle applicative
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE users, products, orders, sessions, refresh_tokens
TO app_user;
-- Sequence per gli auto-increment
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
-- Nega DROP, TRUNCATE, ALTER (non assegnati = negati per default)
-- Nega l'accesso alle tabelle di sistema
REVOKE ALL ON ALL TABLES IN SCHEMA information_schema FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA pg_catalog FROM PUBLIC;
-- Timeout per prevenire attacchi time-based con sleep lunghe
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET lock_timeout = '10s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';
Per le credenziali del database, non inserire mai stringhe di connessione hardcoded nel codice sorgente. In sviluppo usa un file .env escluso da git. In produzione usa AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, o le variabili d'ambiente dell'orchestratore (Kubernetes Secrets, Docker Secrets). Un segreto leakato nel repository Git può essere recuperato dalla history anche dopo la cancellazione, e va considerato compromesso permanentemente.
Step 10: Logging e Rilevamento degli Attacchi
SQL injection lascia tracce caratteristiche nei log: errori di sintassi SQL, query con tempi di risposta anomali, e pattern di input sospetti. Configura il logging strutturato con Pino per rilevare questi segnali in tempo reale e alimentare il tuo SIEM.
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// Non loggare mai credenziali o dati sensibili
redact: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'*.secret',
],
});
// Pattern caratteristici di SQL injection nell'input
const SQLI_PATTERNS = [
/(\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|EXEC|EXECUTE)\b)/i,
/(--|\/\*|\*\/|;--)/,
/'\s*(OR|AND)\s+['"]?\d+['"]?\s*[=<>]/i,
/pg_sleep|sleep\s*\(|benchmark\s*\(/i,
/information_schema|sys\.tables|sysobjects|sqlite_master/i,
/\bload_file\s*\(|outfile\b/i, // MySQL file read/write
];
function hasSQLiSignature(value) {
return typeof value === 'string' && SQLI_PATTERNS.some(p => p.test(value));
}
// Middleware di rilevamento: blocca prima del controller
function sqliDetectionMiddleware(req, res, next) {
const inputs = [
...Object.values(req.query ?? {}),
...Object.values(req.body ?? {}),
...Object.values(req.params ?? {}),
].filter(v => typeof v === 'string');
if (inputs.some(hasSQLiSignature)) {
logger.warn({
event: 'sqli_attempt',
ip: req.ip,
method: req.method,
path: req.path,
ua: req.get('User-Agent'),
// Logga solo i nomi dei campi sospetti, non i valori (evita log injection)
fields: [
...Object.keys(req.query ?? {}),
...Object.keys(req.body ?? {}),
],
});
return res.status(400).json({ error: 'Richiesta non valida' });
}
next();
}
app.use('/api/', sqliDetectionMiddleware);
// Middleware per query DB lente (potenziali attacchi time-based)
pool.on('connect', client => {
const originalQuery = client.query.bind(client);
client.query = function (...args) {
const start = Date.now();
const promise = originalQuery.apply(this, args);
Promise.resolve(promise).then(() => {
const ms = Date.now() - start;
if (ms > 2000) {
logger.warn({ event: 'slow_db_query', duration_ms: ms });
}
}).catch(() => {});
return promise;
};
});
Step 11: Testing Automatico per SQL Injection
Ogni endpoint che riceve input utente deve essere testato per SQL injection. Integra i test automatici nella pipeline CI/CD con Jest e Supertest per bloccare le regressioni prima che raggiungano la produzione.
// tests/sqli.test.js
const request = require('supertest');
const app = require('../app');
// Payload di test standard da OWASP Testing Guide
const SQLI_PAYLOADS = [
"' OR '1'='1",
"' OR '1'='1' --",
"' OR '1'='1' /*",
"' UNION SELECT null, username, password FROM users --",
"'; DROP TABLE users; --",
"1 OR 1=1",
"1; SELECT * FROM users",
"' OR pg_sleep(2) --", // Time-based (PostgreSQL)
"admin'--",
"%27 OR %271%27=%271", // URL-encoded
"' OR 1=1#", // MySQL comment
"1 OR 1=1--", // Senza apice (colonne numeriche)
];
describe('SQL Injection Prevention', () => {
describe('GET /api/users - ricerca username', () => {
SQLI_PAYLOADS.forEach((payload) => {
test(`rifiuta o gestisce correttamente payload: ${payload.slice(0, 25)}...`, async () => {
const res = await request(app)
.get('/api/users')
.query({ q: payload });
// Non deve mai restituire un errore 500 con dettagli SQL
expect(res.status).not.toBe(500);
const body = JSON.stringify(res.body);
// Non deve esporre messaggi di errore interni del DB
expect(body).not.toMatch(/syntax error/i);
expect(body).not.toMatch(/postgresql/i);
expect(body).not.toMatch(/sql state/i);
expect(body).not.toMatch(/column .+ does not exist/i);
// Se risponde 200, non deve restituire più utenti del previsto
if (res.status === 200 && Array.isArray(res.body.data)) {
// Un payload SQLi che bypassa WHERE restituirebbe tutti gli utenti
// Un'app con 1000+ utenti non dovrebbe restituire 50+ risultati per un payload
expect(res.body.data.length).toBeLessThan(50);
}
}, 5000); // Timeout 5s per rilevare attacchi time-based
});
});
test('POST /api/users rifiuta email con caratteri SQL', async () => {
const res = await request(app)
.post('/api/users')
.send({
username: 'testuser',
email: "[email protected]'; DROP TABLE users; --",
password: 'ValidPassword123!',
});
expect([400, 422]).toContain(res.status);
});
test('GET /api/users/:id con ID non numerico ritorna 400', async () => {
const res = await request(app)
.get("/api/users/' OR 1=1 --");
expect([400, 404]).toContain(res.status);
});
});
Affianca i test unitari con OWASP ZAP in modalità automatica. Aggiungi questo step alla pipeline CI/CD di GitHub Actions o GitLab CI per eseguire uno scan dell'ambiente di staging prima di ogni deploy in produzione. ZAP rileva varianti di SQL injection che i test unitari possono perdere, incluse le tecniche blind e time-based.
Step 12: Difese in Profondità e Progetto Completo
Le query parametrizzate sono la difesa primaria. Le misure seguenti creano strati di protezione indipendenti che limitano il danno anche se un singolo livello fallisce per un errore umano o una configurazione errata.
Web Application Firewall (WAF): Cloudflare WAF, AWS WAF con le managed rules OWASP Core Rule Set (CRS), o ModSecurity (open source) analizzano il traffico HTTP prima che raggiunga Node.js e bloccano i pattern SQLi più comuni. Attiva le OWASP CRS managed rules sul tuo WAF e configura il modo "block" invece di "log only".
Row-Level Security (RLS) in PostgreSQL: Abilita RLS sulle tabelle con dati multi-tenant. Con RLS, l'utente app_user vede solo le righe del tenant corrente, limitando il danno di una injection al singolo tenant anche se l'attaccante riesce a bypassare la logica applicativa.
-- PostgreSQL RLS: isolamento per tenant
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.tenant_id', true)::int);
-- In Node.js: imposta il contesto del tenant all'inizio di ogni richiesta
async function withTenantContext(pool, tenantId, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Il cast a intero previene injection anche qui
await client.query(
`SET LOCAL app.tenant_id = ${parseInt(tenantId, 10)}`
);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Il progetto completo che integra tutti i pattern di questa guida è disponibile su GitHub. La struttura finale del progetto include:
sqli-safe-api/
├── app.js # Entry point, middleware di sicurezza
├── drivers/
│ ├── postgres.js # Pool PostgreSQL con timeout e SSL
│ └── mysql.js # Pool MySQL con multipleStatements: false
├── middleware/
│ ├── validate.js # Middleware Zod generico
│ ├── sqliDetect.js # Rilevamento pattern SQLi
│ └── errorHandler.js # Error handler centrale (niente stack trace)
├── routes/
│ ├── users.js # Endpoint utenti con query parametrizzate
│ └── products.js # Endpoint prodotti con Knex.js
├── schemas/
│ ├── user.js # Schema Zod per User
│ └── product.js # Schema Zod per Product
├── tests/
│ └── sqli.test.js # Test automatici SQLi con payload OWASP
├── prisma/
│ └── schema.prisma # Schema Prisma ORM
├── .env.example # Template variabili d'ambiente (no credenziali)
└── package.json
Errori Comuni (Pitfall da Evitare)
Questi sono gli errori più frequenti che vanificano la protezione da SQL injection in applicazioni Node.js reali, ordinati per frequenza riscontrata nei code review di sicurezza.
Pitfall 1: Usare .query() invece di .execute() con mysql2. Con mysql2, connection.query() usa l'interpolazione lato client (escape di stringhe), non prepared statement veri a livello di protocollo MySQL. connection.execute() invia la query e i parametri al server in messaggi separati. Per input utente, usa sempre execute(). L'eccezione è la query con array IN(), dove serve costruire i placeholder dinamicamente.
Pitfall 2: prisma.$queryRawUnsafe() con input utente. Questo metodo è equivalente alla concatenazione manuale. Esiste per casi in cui la query deve essere costruita programmaticamente con valori già validati. Usalo solo con stringhe completamente statiche o con valori passati come argomento separato alla funzione, mai con template string che interpolano input utente.
Pitfall 3: Filtrare solo gli apici singoli. Rimuovere o fare escape degli apici non è sufficiente. Molte varianti di SQL injection non usano apici: 1 OR 1=1 su campi numerici, injection via commenti /**/, doppio encoding Unicode. L'unica difesa affidabile è la parametrizzazione.
Pitfall 4: Esporre messaggi di errore SQL al client. Un messaggio come ERROR: syntax error at or near "'" o Table 'users' doesn't exist conferma la vulnerabilità e rivela informazioni sulla struttura del database. Usa un error handler centrale che mappi tutti gli errori DB a { error: 'Errore interno del server' }.
Pitfall 5: Non validare il tipo dei parametri numerici. Se un endpoint accetta un ID numerico via URL (es. /api/users/:id) e non valida che sia effettivamente un intero, un input come 1 OR 1=1 può raggiungere la query. Converti e valida sempre: const id = parseInt(req.params.id, 10); if (isNaN(id)) return 400;.
Pitfall 6: Ordinamento dinamico senza whitelist. Costruire ORDER BY ${req.query.sort} è vulnerabile anche se i valori della clausola WHERE sono parametrizzati. I nomi di identificatori SQL non possono essere parametrizzati: usa sempre una whitelist con un Set di colonne consentite.
Pitfall 7: Second-order injection. L'input viene memorizzato nel DB (correttamente, con parametrizzazione) e poi riusato in una query successiva senza parametrizzazione. Esempio: un username memorizzato come admin'-- che viene poi usato per costruire una query di report. Parametrizza ogni query che usa dati provenienti dal DB, anche se quei dati erano stati inseriti in modo sicuro.
Risoluzione dei Problemi Comuni
| Problema | Causa probabile | Soluzione |
|---|---|---|
| Query parametrizzata ritorna 0 risultati dove ci si aspettano dati | Tipo del parametro non corrisponde alla colonna (stringa vs intero) | Cast esplicito: $1::int oppure parseInt(value, 10) prima del parametro |
| Errore "invalid input syntax for type integer" | Input non numerico su una colonna intera | Valida prima con Zod: z.coerce.number().int(); ritorna 400 se fallisce |
| ILIKE con parametro non funziona con pg | Il wildcard % deve stare nel valore, non nella query template | Usa values: ['%' + search + '%'] oppure ILIKE '%' || $1 || '%' nella query |
| Errore "prepared statement X already exists" con pg | Stesso nome statement usato per query diverse sulla stessa connessione | Usa nomi univoci per statement, oppure evita i named statement con pool |
| mysql2 execute() lancia errore su IN(?) | mysql2 non espande automaticamente gli array per execute() | Genera i placeholder: ids.map(() => '?').join(',') e usa pool.query() |
| Prisma $queryRaw lancia errore di tipo | Uso di template literal non-tagged invece di tagged template | Sintassi corretta: prisma.$queryRaw\`SELECT ... WHERE id = ${id}\` |
| ZAP segnala SQLi nonostante le parametrizzazioni | ZAP usa fuzzing: rileva differenze di comportamento tra input valido e invalido | Normalizza i messaggi di errore: stessa struttura JSON per 400, 404, 500 |
| Test SQLi passano in locale ma falliscono in staging | Versione diversa del driver o configurazione del pool diversa | Pinna le versioni esatte in package-lock.json; usa lo stesso .env in entrambi gli ambienti |
Consigli Avanzati per la Produzione
Analisi statica (SAST) con Semgrep: Integra Semgrep nella pipeline CI/CD con le regole del registry r2c-security-audit. Semgrep rileva automaticamente i pattern di concatenazione di input in query SQL nel codice Node.js e può essere configurato con regole personalizzate per le specifiche del progetto. Si integra con GitHub Actions in 5 righe di YAML e blocca il PR se trova vulnerabilità.
Database Activity Monitoring (DAM): Abilita pgaudit su PostgreSQL per registrare ogni query eseguita con l'utente che l'ha lanciata, il timestamp, e se ha avuto successo. In caso di incidente, hai un audit trail completo. Configura alert via il tuo SIEM (Elastic, Splunk) per pattern anomali: numero di righe estratte superiore alla media, query UNION non attese, esecuzione di funzioni come pg_sleep o load_file.
Rotazione automatica delle credenziali: In ambienti cloud, configura la rotazione automatica delle password del database tramite AWS Secrets Manager Rotation Lambda, GCP Secret Manager, o Vault Dynamic Secrets. Il pool di connessioni deve ricaricare le credenziali senza restart dell'applicazione: usa l'hook connect del pool per aggiornare le credenziali alla creazione di ogni nuova connessione.
Analisi delle dipendenze con npm audit e Socket.dev: Esegui npm audit --audit-level=high in CI e blocca il deploy se rileva vulnerabilità HIGH o CRITICAL nelle dipendenze. Integra Socket.dev per il monitoraggio in tempo reale delle supply chain attack sulle dipendenze npm, incluse le librerie di accesso al DB.
Copertura Correlata
Approfondisci la sicurezza delle applicazioni Node.js con queste guide pratiche su shattered.io.
- OWASP Top 10 2025 in Node.js: 10 Vulnerabilità, 12 Difese - la panoramica completa delle vulnerabilità più critiche e come mitigarle
- Validazione Input in Node.js con Zod, Joi e express-validator in 12 Step - validazione approfondita per tutti i tipi di input con schemi rigorosi
- Protezione CSRF in Node.js - difendersi dagli attacchi Cross-Site Request Forgery con token e SameSite cookies
- Rate Limiting in Node.js: API Sicura in 12 Step - limitare i tentativi di brute force e abuse con express-rate-limit e Redis
- Autenticazione JWT in Node.js: 12 Step - gestione sicura dei JSON Web Token con rotazione e revoca
Risorse Esterne
- OWASP SQL Injection Prevention Cheat Sheet - riferimento definitivo per le contromisure, con esempi per ogni framework
- PortSwigger Web Security Academy: SQL Injection - laboratori pratici e spiegazioni approfondite sulle tecniche di attacco
- OWASP Top 10 Project - lista ufficiale delle 10 vulnerabilità più critiche per le applicazioni web
- Sequelize Raw Queries Documentation - guida ufficiale alle query raw sicure con replacements e bind
- OWASP ZAP (Zed Attack Proxy) - scanner di vulnerabilità open source per test automatici SQLi
FAQ
Le query parametrizzate proteggono da tutte le varianti di SQL injection?
Le query parametrizzate proteggono da tutte le varianti di injection nei valori: classica, UNION-based, blind boolean e time-based. Non proteggono dai nomi di colonne o tabelle dinamici, per i quali serve la whitelist. Non proteggono nemmeno dagli attacchi second-order injection, dove l'input viene memorizzato nel DB e usato successivamente in una query non parametrizzata. Parametrizza ogni query che riceve dati potenzialmente controllati dall'utente, anche indirettamente.
Un ORM come Prisma o Sequelize elimina completamente il rischio?
Gli ORM riducono significativamente il rischio perché le query generate dalle API standard sono sempre parametrizzate. Il rischio rimane con le raw query ($queryRawUnsafe, sequelize.query() senza replacements, knex.raw() senza binding). Tratta ogni raw query con lo stesso rigore di una query scritta a mano.
Qual è la differenza tra escape e parametrizzazione?
L'escape converte i caratteri pericolosi prima di inserirli nella stringa SQL. La parametrizzazione non inserisce mai il valore nella stringa SQL: lo invia al database su un canale separato come valore tipizzato. La parametrizzazione è più sicura perché non dipende dalla correttezza dell'implementazione dell'escape e funziona correttamente anche con encoding inusuali e set di caratteri multibyte.
Come proteggo i nomi di tabelle dinamici?
I nomi di tabelle e colonne non possono essere parametrizzati in SQL. L'unica soluzione sicura è una whitelist: un Set JavaScript con i nomi consentiti. Prima di usare un nome di tabella dinamico, verifica la sua presenza nella whitelist e lancia un errore 400 se non è consentito. Non usare mai l'input utente come nome di tabella o colonna senza whitelist, nemmeno dopo l'escape.
Devo usare una WAF se già uso query parametrizzate?
Sì, come difesa in profondità. Le query parametrizzate sono la difesa primaria e più efficace. Una WAF aggiunge un layer di protezione che blocca attacchi prima che raggiungano l'applicazione, riduce la superficie di attacco per gli errori umani, e rileva tentativi in tempo reale per la risposta agli incidenti. I due strumenti si complementano.
Cosa fare se scopro una SQL injection in produzione?
Passi immediati: 1) Isola l'endpoint vulnerabile con un blocco WAF o disabilitandolo temporaneamente. 2) Analizza i log degli ultimi 90 giorni per verificare se l'attacco è già avvenuto. 3) Controlla l'integrità dei dati sensibili. 4) Applica la patch con query parametrizzate in un branch di emergenza e deploya. 5) Se hai evidenza di exfiltrazione di dati personali, attiva il processo di notifica GDPR: 72 ore per comunicarlo all'autorità di controllo (il Garante Privacy in Italia). 6) Documenta l'incidente nel registro dei trattamenti.
Come verifico se la mia applicazione è vulnerabile?
Usa OWASP ZAP per uno scan automatico in staging. Integra Semgrep con le regole r2c-security-audit per l'analisi statica del codice. Esegui i test manuali con i payload descritti nello Step 11. Per un'analisi professionale, affida un penetration test a un esperto certificato (OSCP, CEH, GPEN) almeno una volta all'anno.
Le stored procedure sono più sicure delle query parametrizzate?
Le stored procedure offrono un livello di sicurezza simile alle query parametrizzate quando i parametri vengono passati correttamente. Il vantaggio aggiuntivo è che incapsulano la logica SQL nel database, riducendo il codice esposto nell'applicazione. Lo svantaggio è la maggiore complessità di manutenzione e il lock-in verso un database specifico. Per la maggior parte delle applicazioni Node.js, le query parametrizzate con un ORM offrono il miglior equilibrio tra sicurezza, portabilità e manutenibilità.




