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 SQLiTecnicaRilevabilitàDanno tipico
In-band classicaApice + iniezione direttaAlta (errori visibili)Dump completo del DB
UNION-basedUNION SELECT su colonne aggiuntiveMediaLettura di qualsiasi tabella
Blind booleanCondizioni vero/falsoBassaEstrazione lenta dei dati
Time-based blindSLEEP() / pg_sleep()Molto bassaEstrazione via timing
Out-of-bandDNS/HTTP exfiltrationQuasi nullaEsfiltrazione 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:

LibreriaVersioneDatabase supportatiTipo
pg (node-postgres)8.xPostgreSQLDriver nativo
mysql23.xMySQL, MariaDBDriver nativo
Prisma6.xPostgres, MySQL, SQLite, MSSQLORM type-safe
Sequelize6.xPostgres, MySQL, SQLite, MSSQLORM classico
Knex.js3.xPostgres, MySQL, SQLite, MSSQLQuery builder
zod3.xn/aValidazione schema
helmet8.xn/aHeader 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

ProblemaCausa probabileSoluzione
Query parametrizzata ritorna 0 risultati dove ci si aspettano datiTipo 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 interaValida prima con Zod: z.coerce.number().int(); ritorna 400 se fallisce
ILIKE con parametro non funziona con pgIl wildcard % deve stare nel valore, non nella query templateUsa values: ['%' + search + '%'] oppure ILIKE '%' || $1 || '%' nella query
Errore "prepared statement X already exists" con pgStesso nome statement usato per query diverse sulla stessa connessioneUsa 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 tipoUso di template literal non-tagged invece di tagged templateSintassi corretta: prisma.$queryRaw\`SELECT ... WHERE id = ${id}\`
ZAP segnala SQLi nonostante le parametrizzazioniZAP usa fuzzing: rileva differenze di comportamento tra input valido e invalidoNormalizza i messaggi di errore: stessa struttura JSON per 400, 404, 500
Test SQLi passano in locale ma falliscono in stagingVersione diversa del driver o configurazione del pool diversaPinna 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.

Risorse Esterne

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à.