La validazione degli input in Node.js non è un dettaglio di implementazione: è la prima linea di difesa contro SQL injection, XSS, command injection e una dozzina di altri attacchi classificati da OWASP come A03:2021. Un form non validato può esporre il tuo database in meno di 60 secondi. Questa guida mostra come usare Zod 4.4.3 (201 milioni di download settimanali su npm), Joi 18.2.3 (23 milioni) ed express-validator 7.3.2 (2 milioni) per costruire un’API Express sicura in 12 step concreti, con esempi di codice pronti per la produzione.

Perché la validazione degli input è critica nel 2026

OWASP Top 10 2021 posiziona l’injection al terzo posto (A03:2021), con SQL injection e XSS che restano le vulnerabilità più sfruttate nelle applicazioni web. Il rilascio di sicurezza di Node.js del 24 marzo 2026 ha corretto 8 CVE sulle release attive v20.x, v22.x e v24.x, di cui 2 ad alta gravità, 4 a gravità media e 2 a bassa gravità. Il rilascio di sicurezza di gennaio 2026 aveva già corretto 3 problemi di alta gravità. Questo ritmo di patch conferma che anche il runtime stesso è un vettore di attacco attivo, non solo il codice applicativo.

Express 5.2.1 non filtra automaticamente nessun dato in ingresso: tutto ciò che arriva in req.body, req.params e req.query è una stringa non attendibile senza validazione esplicita. Un attaccante che invia {"isAdmin": true} insieme a dati legittimi può scalare privilegi se il controller non rimuove i campi non attesi. La validazione previene questo scenario prima ancora che i dati raggiungano la logica di business.

Il vantaggio delle librerie di validazione rispetto al codice manuale è la standardizzazione degli errori, la composizione degli schemi e la sanitizzazione integrata. Tre librerie coprono quasi ogni caso d’uso con API diverse e punti di forza complementari, che imparerai a confrontare e scegliere in questa guida.

Prerequisiti

RequisitoVersioneNote
Node.js LTSv24.17.0 (Krypton, rilasciato 17 giugno 2026)Compatibile anche con v20.x e v22.x
npm11.13.0Incluso con Node.js v24
Express5.2.1Versione stabile attuale
express-validator7.3.2Middleware nativo Express
Joi18.2.3Schema validation enterprise
Zod4.4.3TypeScript-first, 201M download/settimana
TypeScript (opzionale)5.8.3Richiesto per la type inference di Zod
ConoscenzeJavaScript ES2022, REST API, HTTPAsync/await, destructuring

Step 1: Creazione del progetto Express

Crea una directory pulita e inizializza il progetto con le dipendenze principali. Express 5 include il supporto nativo alle promise nelle route handler, eliminando la necessità di express-async-errors. I middleware si registrano in ordine: prima JSON parsing, poi le route, infine il gestore di errori globale.

mkdir node-validation-demo && cd node-validation-demo
npm init -y

# Dipendenze produzione
npm install [email protected] \
  [email protected] \
  [email protected] \
  [email protected] \
  [email protected] \
  [email protected] \
  [email protected] \
  [email protected]

# Dipendenze sviluppo
npm install --save-dev \
  [email protected] \
  [email protected] \
  @types/node \
  @types/express \
  @types/sanitize-html \
  jest@29 \
  supertest@7 \
  @types/supertest

Crea il file principale dell’applicazione con i middleware di sicurezza di base. Il limite di 10 KB sul body previene gli attacchi di tipo body-flooding. La configurazione trust proxy è necessaria se l’app gira dietro Nginx o un load balancer per ottenere l’IP reale del client.

// app.js
const express = require('express');
const app = express();

// Parsing del body con limite di dimensione
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// Gestione errori globale (Express 5: gestisce anche errori async)
app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large') {
    return res.status(413).json({ success: false, message: 'Payload troppo grande' });
  }
  console.error(err.stack);
  res.status(500).json({ success: false, message: 'Errore interno del server' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server attivo su porta ${PORT}`));

module.exports = app;

Step 2: Prima validazione con express-validator

express-validator 7.3.2 offre un’API a catena direttamente nelle route Express. Le funzioni body(), param() e query() coprono tutte le sorgenti di input. Ogni chiamata alla catena aggiunge una regola di validazione; la funzione validationResult(req) raccoglie tutti gli errori accumulati a fine catena.

// routes/users.js
const express = require('express');
const { body, param, query, validationResult } = require('express-validator');
const router = express.Router();

// Middleware riutilizzabile: raccoglie errori e risponde con 422
const handleValidation = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json({
      success: false,
      errors: errors.array().map(e => ({
        field: e.path,
        message: e.msg,
        received: e.value
      }))
    });
  }
  next();
};

// Route di registrazione con validazione completa
router.post(
  '/register',
  [
    body('username')
      .trim()
      .isLength({ min: 3, max: 30 })
      .withMessage('Username: da 3 a 30 caratteri')
      .matches(/^[a-zA-Z0-9_]+$/)
      .withMessage('Username: solo lettere, numeri e underscore')
      .toLowerCase(),

    body('email')
      .trim()
      .normalizeEmail()
      .isEmail()
      .withMessage('Indirizzo email non valido'),

    body('password')
      .isLength({ min: 12 })
      .withMessage('Password: minimo 12 caratteri')
      .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
      .withMessage('Richiesta: maiuscola, minuscola, numero e simbolo'),

    body('age')
      .optional()
      .isInt({ min: 13, max: 120 })
      .withMessage('Età: valore tra 13 e 120')
      .toInt(),

    body('website')
      .optional()
      .isURL({ protocols: ['https'], require_protocol: true })
      .withMessage('URL deve usare HTTPS'),
  ],
  handleValidation,
  async (req, res) => {
    const { username, email, age, website } = req.body;
    // I dati sono stati validati e sanitizzati: sicuri da usare
    res.status(201).json({
      success: true,
      data: { username, email, age, website }
    });
  }
);

module.exports = router;

Il middleware handleValidation centralizza la gestione degli errori. Questo pattern elimina la duplicazione del codice di controllo in ogni route. La risposta 422 (Unprocessable Entity) è più semanticamente corretta di 400 (Bad Request) per errori di validazione: il server ha capito la richiesta, ma i dati non sono processabili.

Step 3: Sanitizzazione integrata con express-validator

La validazione dice se un dato è accettabile. La sanitizzazione trasforma i dati in una forma sicura prima che raggiungano il database o vengano restituiti al client. express-validator include sanitizzatori integrati che operano nella stessa catena di validazione, senza bisogno di librerie aggiuntive per i casi più comuni.

SanitizzatoreCosa faQuando usarlo
.trim()Rimuove spazi iniziali e finaliTutti i campi stringa
.escape()Converte < > & " ' in entità HTMLTesto plain visualizzato in HTML
.normalizeEmail()Lowercase, rimuove alias Gmail (+tag)Campi email
.toInt()Converte stringa in interoID, età, quantità
.toFloat()Converte stringa in floatPrezzi, coordinate GPS
.toBoolean()Converte in booleanFlag, preferenze utente
.toLowerCase()Tutto in minuscoloUsername, slug, codici
.stripLow()Rimuove caratteri di controllo ASCIICampi testo libero

Attenzione critica: .escape() converte <b>testo</b> in &lt;b&gt;testo&lt;/b&gt;, rompendo la formattazione HTML. Se permetti HTML ricco (editor WYSIWYG, commenti formattati), usa sanitize-html al posto di .escape(). Vedi Step 9 per la configurazione completa.

Step 4: Schema validation con Joi

Joi 18.2.3 definisce schemi di validazione come oggetti JavaScript separati dalle route. Questo approccio è preferibile quando hai schemi complessi, dipendenze condizionali tra campi (Joi.when()) o quando vuoi riutilizzare lo stesso schema in più parti dell’applicazione. Con 23 milioni di download settimanali, Joi è la scelta dominante nei backend enterprise Node.js.

// schemas/productSchema.js
const Joi = require('joi');

const productSchema = Joi.object({
  name: Joi.string()
    .trim()
    .min(2)
    .max(100)
    .required()
    .messages({
      'string.min': 'Il nome deve avere almeno 2 caratteri',
      'string.max': 'Il nome non può superare 100 caratteri',
      'any.required': 'Il nome è obbligatorio'
    }),

  price: Joi.number()
    .positive()
    .precision(2)
    .max(99999.99)
    .required()
    .messages({
      'number.positive': 'Il prezzo deve essere positivo',
      'number.max': 'Prezzo massimo: 99.999,99 €'
    }),

  category: Joi.string()
    .valid('elettronica', 'abbigliamento', 'alimentari', 'sport', 'casa')
    .required()
    .messages({
      'any.only': 'Categoria non valida. Valori accettati: elettronica, abbigliamento, alimentari, sport, casa'
    }),

  tags: Joi.array()
    .items(Joi.string().trim().lowercase().max(30))
    .max(10)
    .unique()
    .default([]),

  description: Joi.string().trim().max(2000).optional().allow(''),

  sku: Joi.string()
    .uppercase()
    .pattern(/^[A-Z]{3}-\d{6}$/)
    .required()
    .messages({
      'string.pattern.base': 'Formato SKU: XXX-000000 (3 lettere, trattino, 6 cifre)'
    }),

  // Validazione condizionale: se isDigital è true, downloadUrl è obbligatorio
  isDigital: Joi.boolean().default(false),
  downloadUrl: Joi.when('isDigital', {
    is: true,
    then: Joi.string().uri({ scheme: ['https'] }).required()
      .messages({ 'any.required': 'URL download obbligatorio per prodotti digitali' }),
    otherwise: Joi.forbidden()
  })
});

// Middleware di validazione Joi
const validateProduct = (req, res, next) => {
  const { error, value } = productSchema.validate(req.body, {
    abortEarly: false,    // Raccoglie tutti gli errori, non solo il primo
    stripUnknown: true,   // Rimuove campi extra (previene mass assignment)
    convert: true         // Converte i tipi automaticamente
  });

  if (error) {
    return res.status(422).json({
      success: false,
      errors: error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }))
    });
  }

  req.body = value; // Sostituisce il body con i dati validati/sanitizzati
  next();
};

module.exports = { productSchema, validateProduct };

L’opzione stripUnknown: true è la difesa più importante contro gli attacchi di mass-assignment. Rimuove silenziosamente tutti i campi non dichiarati nello schema prima che raggiungano il controller. Senza di essa, un attaccante può inviare {"name": "Prodotto", "isAdmin": true} e il campo isAdmin arriverà intatto al controller e poi al database.

Step 5: TypeScript-first validation con Zod

Zod 4.4.3 ha superato 201 milioni di download settimanali, diventando la libreria di validazione più scaricata su npm. Il suo vantaggio principale rispetto a Joi è l’inferenza automatica dei tipi TypeScript dagli schemi: definisci lo schema una volta e ottieni automaticamente il tipo corrispondente senza duplicazione di codice. Zod 4 introduce performance significativamente migliorate rispetto a Zod 3 e una sintassi più compatta per i messaggi di errore.

// schemas/orderSchema.ts
import { z } from 'zod';

const OrderItemSchema = z.object({
  productId: z.string().uuid({ message: 'ID prodotto non valido' }),
  quantity: z.number().int().positive().max(100, 'Quantità massima: 100'),
  unitPrice: z.number().positive().max(10000, 'Prezzo unitario massimo: 10.000 €')
});

const OrderSchema = z.object({
  customerId: z.string().uuid({ message: 'ID cliente non valido' }),

  items: z
    .array(OrderItemSchema)
    .min(1, "L'ordine deve contenere almeno un articolo")
    .max(50, 'Massimo 50 articoli per ordine'),

  shippingAddress: z.object({
    street: z.string().trim().min(5).max(200),
    city: z.string().trim().min(2).max(100),
    postalCode: z.string().regex(/^\d{5}$/, 'CAP italiano: 5 cifre'),
    country: z.enum(['IT', 'DE', 'FR', 'ES', 'NL', 'BE']).default('IT')
  }),

  couponCode: z
    .string()
    .trim()
    .toUpperCase()
    .regex(/^[A-Z0-9]{6,12}$/, 'Codice coupon non valido')
    .optional(),

  paymentMethod: z.enum(['carta', 'paypal', 'bonifico', 'contrassegno'], {
    message: 'Metodo di pagamento non supportato'
  }),

  notes: z.string().trim().max(500).optional()
});

// Inferenza automatica del tipo TypeScript
type Order = z.infer;
type OrderItem = z.infer;

// Middleware Express con Zod
import { Request, Response, NextFunction } from 'express';

const validateOrder = (req: Request, res: Response, next: NextFunction): void => {
  const result = OrderSchema.safeParse(req.body);

  if (!result.success) {
    res.status(422).json({
      success: false,
      errors: result.error.issues.map(issue => ({
        field: issue.path.join('.'),
        message: issue.message,
        code: issue.code
      }))
    });
    return;
  }

  req.body = result.data;
  next();
};

export { OrderSchema, Order, OrderItem, validateOrder };

Usa sempre safeParse() nelle API, mai parse(): safeParse() restituisce un oggetto con success booleano senza mai lanciare eccezioni, mentre parse() genera un ZodError non gestito se la validazione fallisce. Usa parse() solo nei test dove vuoi che l’errore interrompa il test stesso.

Step 6: Confronto tra le tre librerie

La scelta tra express-validator, Joi e Zod dipende dal contesto del progetto, dalla presenza di TypeScript e dalla complessità degli schemi. Nessuna delle tre è superiore in assoluto: hanno punti di forza diversi che le rendono adatte a scenari diversi.

Caratteristicaexpress-validator 7.3.2Joi 18.2.3Zod 4.4.3
Download settimanali npm2,04 milioni23,2 milioni201,6 milioni
TypeScript supportParziale (tipi base)BuonoEccellente (nativo)
Inferenza tipi automaticaNoNoSi (z.infer)
Integrazione ExpressNativa (middleware)Via factory middlewareVia factory middleware
Bundle size (minified)~50 KB~145 KB~64 KB (v4)
Validazione condizionaleLimitataEccellente (Joi.when)Buona (z.discriminatedUnion)
Validazione asincronaSi (custom validator)Si (validateAsync)Si (z.promise)
Strip campi extraNo (automatico)Si (stripUnknown)Si (strip(), default)
Curva di apprendimentoBassaMediaMedia
Migliore perPrototipi Express, migrazione gradualeBackend enterprise, schemi complessiProgetti TypeScript, full-stack

Step 7: Prevenzione SQL Injection con query parametrizzate

La validazione degli input riduce la superficie di attacco rifiutando dati malformati. Ma non sostituisce le query parametrizzate: anche con input validato, un carattere legittimo come l’apostrofo nel cognome O’Brien può causare SQL injection in una query concatenata. Usa sempre i placeholder del driver del database per separare codice SQL e dati utente.

// db/queries.js
const mysql2 = require('mysql2/promise');

const pool = mysql2.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  namedPlaceholders: true  // Permette l'uso di :nome invece di ?
});

// VULNERABILE - SQL injection possibile
async function getUserByEmailUnsafe(email) {
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  // Un attaccante invia: ' OR '1'='1' --
  // La query diventa: WHERE email = '' OR '1'='1' -- '
  // Restituisce TUTTI gli utenti
  const [rows] = await pool.execute(query);
  return rows[0];
}

// SICURO - query parametrizzata con placeholder
async function getUserByEmail(email) {
  const [rows] = await pool.execute(
    'SELECT id, username, email, created_at FROM users WHERE email = ? AND active = 1',
    [email]  // Il driver esegue l'escape automatico
  );
  return rows[0] || null;
}

// SICURO - ricerca con placeholder multipli
async function searchProducts(name, category, maxPrice) {
  const [rows] = await pool.execute(
    `SELECT id, name, price, category, sku
     FROM products
     WHERE name LIKE ? AND category = ? AND price <= ? AND active = 1
     ORDER BY price ASC
     LIMIT 50`,
    [`%${name.replace(/[%_]/g, '\\$&')}%`, category, maxPrice]
    // Escape dei metacaratteri LIKE: % e _ vengono preceduti da \
  );
  return rows;
}

// SICURO - INSERT con named placeholders
async function createProduct({ name, price, category, sku }) {
  const [result] = await pool.execute(
    `INSERT INTO products (name, price, category, sku, created_at)
     VALUES (:name, :price, :category, :sku, NOW())`,
    { name, price, category, sku }
  );
  return result.insertId;
}

module.exports = { getUserByEmail, searchProducts, createProduct };

Per PostgreSQL con il driver pg, usa $1, $2, $3 come placeholder. Con Prisma o TypeORM, le query parametrizzate sono gestite automaticamente dall'ORM, ma devi comunque validare gli input prima che raggiungano l'ORM per prevenire operazioni non autorizzate come where: {} che in Prisma seleziona tutti i record.

Step 8: Middleware di validazione centralizzato

Un'architettura scalabile separa gli schemi di validazione dalla logica delle route. Il pattern factory crea middleware riutilizzabili a partire dagli schemi Zod o Joi. Ogni route riceve la validazione appropriata con una sola riga di codice, senza ripetere la gestione degli errori.

// middleware/validateSchema.js
const { z } = require('zod');

// Factory: crea un middleware che valida req.body con lo schema Zod fornito
const validateBody = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json({
      success: false,
      errors: result.error.issues.map(i => ({
        field: i.path.join('.'),
        message: i.message
      }))
    });
  }
  req.body = result.data;
  next();
};

// Factory: valida req.params
const validateParams = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.params);
  if (!result.success) {
    return res.status(400).json({
      success: false,
      errors: result.error.issues
    });
  }
  req.params = result.data;
  next();
};

// Factory: valida req.query con coercizione dei tipi (stringa -> numero)
const validateQuery = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.query);
  if (!result.success) {
    return res.status(400).json({
      success: false,
      errors: result.error.issues
    });
  }
  req.query = result.data;
  next();
};

// Schemi esempio
const IdParamSchema = z.object({
  id: z.string().uuid({ message: 'ID non valido: deve essere un UUID' })
});

const PaginationSchema = z.object({
  q: z.string().trim().min(2).max(100).optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(20),
  sort: z.enum(['asc', 'desc']).default('desc')
});

// Utilizzo nelle route
// router.get('/products', validateQuery(PaginationSchema), listProducts);
// router.get('/products/:id', validateParams(IdParamSchema), getProduct);
// router.put('/products/:id', validateParams(IdParamSchema), validateBody(ProductSchema), updateProduct);

module.exports = { validateBody, validateParams, validateQuery, IdParamSchema, PaginationSchema };

Step 9: Prevenzione XSS con sanitize-html

Quando la tua applicazione deve accettare HTML ricco da parte degli utenti (editor WYSIWYG, commenti formattati, descrizioni prodotti), sanitize-html 2.17.5 (9,7 milioni di download settimanali su npm) rimuove selettivamente tag e attributi pericolosi mantenendo la formattazione sicura. La configurazione con whitelist esplicita è la strategia corretta: nega tutto per default e permetti solo ciò che è necessario.

// utils/sanitize.js
const sanitizeHtml = require('sanitize-html');

// Per commenti utente (permissiva)
const commentOptions = {
  allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code'],
  allowedAttributes: {
    'a': ['href', 'title']
  },
  allowedSchemes: ['https', 'mailto'],
  // Blocca javascript: nei link, forza rel="noopener"
  transformTags: {
    'a': (tagName, attribs) => {
      const href = attribs.href || '';
      if (href.startsWith('javascript:') || href.startsWith('data:')) {
        return { tagName: 'span', attribs: {} };
      }
      return {
        tagName: 'a',
        attribs: {
          href: attribs.href,
          rel: 'noopener noreferrer',
          target: '_blank'
        }
      };
    }
  }
};

// Per contenuto editoriale (più permissiva)
const articleOptions = {
  allowedTags: [
    'h2', 'h3', 'h4', 'p', 'br', 'b', 'i', 'strong', 'em',
    'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'table',
    'thead', 'tbody', 'tr', 'th', 'td'
  ],
  allowedAttributes: {
    'a': ['href'],
    'code': ['class'],
    'pre': ['class'],
    'table': ['class']
  },
  allowedSchemes: ['https']
};

// Nessun HTML: rimuove tutto
const stripAll = (dirty) => sanitizeHtml(dirty, {
  allowedTags: [],
  allowedAttributes: {}
});

module.exports = {
  sanitizeComment: (html) => sanitizeHtml(html, commentOptions),
  sanitizeArticle: (html) => sanitizeHtml(html, articleOptions),
  stripAll
};

Salva sempre l'HTML sanitizzato nel database, non l'HTML grezzo. Sanitizzare solo al momento della visualizzazione lascia dati pericolosi persistenti: se la logica di display cambia o viene introdotto un bug che bypassa il sanitizzatore, il payload XSS salvato nel database diventa attivo. La sanitizzazione pre-persist è l'unica strategia difendibile a lungo termine.

Step 10: Validazione file upload sicura

L'upload di file è una delle superfici di attacco più pericolose nelle API REST. Un attaccante può caricare file eseguibili rinominati con estensione immagine, file con nomi che traversano directory come ../../etc/passwd, o file enormi che esauriscono lo storage. La validazione deve operare su tre livelli: estensione dichiarata, tipo MIME dichiarato dal browser e magic bytes reali del file.

// middleware/validateUpload.js
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const { fileTypeFromBuffer } = require('file-type');

const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const MIME_TO_EXT = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };

const storage = multer.diskStorage({
  destination: '/tmp/uploads',
  filename: (req, file, cb) => {
    // Nome file casuale: previene path traversal e caratteri speciali
    const randomName = crypto.randomBytes(16).toString('hex');
    const ext = MIME_TO_EXT[file.mimetype] || '.bin';
    cb(null, `${randomName}${ext}`);
  }
});

const fileFilter = (req, file, cb) => {
  if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
    return cb(new Error(`Tipo file non supportato: ${file.mimetype}`), false);
  }
  const ext = path.extname(file.originalname).toLowerCase();
  if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) {
    return cb(new Error('Estensione file non supportata'), false);
  }
  cb(null, true);
};

const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024,  // 5 MB
    files: 1,
    fields: 5
  }
});

// Verifica il tipo reale tramite magic bytes (non il MIME dichiarato)
const verifyFileContent = async (req, res, next) => {
  if (!req.file) return next();
  const fs = require('fs').promises;
  try {
    const buffer = await fs.readFile(req.file.path);
    const detected = await fileTypeFromBuffer(buffer);
    if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) {
      await fs.unlink(req.file.path);
      return res.status(422).json({
        success: false,
        message: 'Il contenuto del file non corrisponde al tipo dichiarato'
      });
    }
    req.file.verifiedMime = detected.mime;
    next();
  } catch (err) {
    await fs.unlink(req.file.path).catch(() => {});
    next(err);
  }
};

module.exports = { upload, verifyFileContent };

Step 11: Validazione asincrona con accesso al database

Alcune regole di validazione richiedono l'accesso al database: verificare che un username non sia già registrato, che un codice coupon esista e non sia scaduto, che un prodotto appartenga all'utente autenticato prima di permettere la modifica. Tutte e tre le librerie supportano validatori asincroni, con sintassi leggermente diverse.

// Validazione asincrona con express-validator
const { body } = require('express-validator');
const { getUserByEmail } = require('../db/queries');

const uniqueEmailRule = body('email')
  .trim()
  .normalizeEmail()
  .isEmail()
  .withMessage('Email non valida')
  .custom(async (email) => {
    const existing = await getUserByEmail(email);
    if (existing) {
      throw new Error('Email già registrata. Usa "Accedi" oppure "Password dimenticata".');
    }
    // Non è necessario return true; se non si lancia un'eccezione, la validazione passa
  });

// Validazione asincrona con Joi (validateAsync obbligatorio)
const Joi = require('joi');

const registrationSchema = Joi.object({
  username: Joi.string().trim().min(3).max(30)
    .external(async (value) => {
      const taken = await checkUsernameExists(value);
      if (taken) throw new Error('Username non disponibile: scegli un altro');
    })
    .required(),
  email: Joi.string().email().lowercase().required()
});

const validateRegistration = async (req, res, next) => {
  try {
    // IMPORTANTE: per schemi con validatori asincroni usa validateAsync, non validate
    const value = await registrationSchema.validateAsync(req.body, {
      abortEarly: false,
      stripUnknown: true
    });
    req.body = value;
    next();
  } catch (error) {
    if (error.isJoi) {
      return res.status(422).json({
        success: false,
        errors: error.details.map(d => ({ field: d.path[0], message: d.message }))
      });
    }
    next(error); // Errori di database passati al gestore errori globale
  }
};

Step 12: Testing della validazione con Jest e Supertest

I test di validazione devono coprire sia il percorso felice (input validi) sia ogni caso di errore: campi mancanti obbligatori, formati errati, valori fuori range, payload SQL injection, payload XSS e tentativi di mass-assignment. Un test per ogni regola di validazione è il livello minimo accettabile per la produzione.

// tests/validation.test.js
const request = require('supertest');
const app = require('../app');

describe('Validazione input API utenti', () => {

  test('Registrazione con dati validi: risposta 201', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({ username: 'mario_rossi', email: '[email protected]', password: 'Secure!Pass2026' });
    expect(res.status).toBe(201);
    expect(res.body.success).toBe(true);
  });

  test('SQL injection nel campo username: risposta 422', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({
        username: "'; DROP TABLE users; --",
        email: '[email protected]',
        password: 'Secure!Pass2026'
      });
    expect(res.status).toBe(422);
    expect(res.body.errors.some(e => e.field === 'username')).toBe(true);
  });

  test('XSS nel campo email: risposta 422', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({
        username: 'mariorossi',
        email: '@evil.com',
        password: 'Secure!Pass2026'
      });
    expect(res.status).toBe(422);
  });

  test('Mass assignment con isAdmin=true: campo rimosso dalla risposta', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({
        username: 'giulia_bianchi',
        email: '[email protected]',
        password: 'Secure!Pass2026',
        isAdmin: true,
        role: 'superadmin'
      });
    // I campi extra devono essere rimossi dallo schema
    expect(res.body.data?.isAdmin).toBeUndefined();
    expect(res.body.data?.role).toBeUndefined();
  });

  test('Password senza simboli: risposta 422 con campo password', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({ username: 'luca_verdi', email: '[email protected]', password: 'semplicissima123' });
    expect(res.status).toBe(422);
    expect(res.body.errors.some(e => e.field === 'password')).toBe(true);
  });

  test('Payload oltre 10 KB: risposta 413', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({ username: 'a'.repeat(11000) });
    expect(res.status).toBe(413);
  });

  test('Raccolta errori multipli (abortEarly: false)', async () => {
    const res = await request(app)
      .post('/api/users/register')
      .send({ username: 'x', email: 'non-una-email', password: 'corta' });
    expect(res.status).toBe(422);
    // Almeno 3 errori: username troppo corto, email invalida, password troppo corta
    expect(res.body.errors.length).toBeGreaterThanOrEqual(3);
  });
});

7 errori comuni nella validazione degli input

1. Validare solo lato client. JavaScript nel browser può essere disabilitato o aggirato con curl, Postman o Burp Suite. La validazione lato client è una comodità UX, non una misura di sicurezza. Ogni dato deve essere rivalidato sul server prima di essere elaborato, indipendentemente dalla validazione front-end già implementata.

2. Non usare abortEarly: false in Joi. Con la configurazione di default, Joi restituisce solo il primo errore trovato. L'utente deve correggere il form un campo alla volta per scoprire tutti gli errori. Imposta sempre abortEarly: false nei middleware di validazione per raccogliere e restituire tutti gli errori in una sola risposta.

3. Omettere stripUnknown: true in Joi o non usare .strip() in Zod. Senza questa opzione, i campi extra inviati dall'utente (come isAdmin, role, balance) passano la validazione e raggiungono il database. In Zod il comportamento di default è già strip, ma in Joi devi attivarlo esplicitamente.

4. Usare .escape() su HTML ricco. body('bio').escape() converte <b>testo</b> in &lt;b&gt;, rompendo qualsiasi formattazione HTML. Per testo che deve contenere HTML sicuro (bio utente, descrizioni prodotto), usa sanitize-html con una whitelist di tag permessi.

5. Validare i file solo per estensione o MIME dichiarato. Il browser invia il tipo MIME basandosi sull'estensione del file dichiarata dal sistema operativo. Rinominare malware.php in foto.jpg inganna la validazione basata solo su MIME. La verifica tramite magic bytes con file-type analizza i byte iniziali del file e rileva il tipo reale indipendentemente dall'estensione.

6. Non validare i parametri URL. req.params.id è sempre una stringa. Una route GET /users/:id con id = "../admin" o id = "0 UNION SELECT * FROM users--" può causare comportamenti inattesi. Valida sempre i parametri URL con param() in express-validator o con validateParams() e uno schema Zod/Joi.

7. Usare validatori asincroni senza validateAsync in Joi. Se uno schema Joi include .external() o Joi.when() con funzioni async, chiamare schema.validate() invece di schema.validateAsync() ignora silenziosamente le promesse dei validatori asincroni. Tutti i validatori async vengono saltati senza errori, rendendo la validazione parziale e potenzialmente insicura.

Troubleshooting: 8 problemi frequenti

Il body della richiesta è undefined. Express non analizza il body JSON automaticamente. Verifica che app.use(express.json()) sia dichiarato prima di tutte le route nel file principale. In Express 5.2.1 questo middleware è ancora necessario esplicitamente.

validationResult restituisce sempre un array vuoto. Le regole di express-validator devono essere eseguite come middleware prima del controller. Verifica che la route includa le regole come array: router.post('/path', [body('field').isEmail()], handleValidation, controller). Se il middleware manca o è nell'ordine sbagliato, la validazione non viene mai eseguita.

Joi non valida oggetti annidati. La validazione di oggetti annidati richiede Joi.object() ricorsivi. Definire solo Joi.string() su un campo che riceve un oggetto genera un errore di tipo generico, non di validazione del contenuto interno. Usa la struttura: address: Joi.object({ street: Joi.string().required() }).

Zod 4 rompe il codice scritto per Zod 3. Zod 4 ha introdotto breaking changes rispetto a Zod 3. La sintassi per i messaggi di errore è cambiata in alcune funzioni. Prima di aggiornare un progetto da Zod 3 a Zod 4, consulta il changelog ufficiale e verifica i test. Il pacchetto zod-v3 esiste per chi deve mantenere la compatibilità con Zod 3.

sanitize-html rimuove tutto l'HTML. Le versioni recenti di sanitize-html hanno una whitelist di default vuota. Se chiami sanitizeHtml(html) senza opzioni, tutto l'HTML viene rimosso. Specifica sempre le opzioni: usa allowedTags: sanitizeHtml.defaults.allowedTags per la lista di default o definisci la tua whitelist.

Multer non rileva il tipo MIME corretto. file.mimetype in Multer rispecchia il Content-Type inviato dal browser, non il contenuto reale del file. Un file PHP rinominato in .jpg passa la validazione MIME. Usa fileTypeFromBuffer() dopo l'upload temporaneo per verificare il tipo reale tramite i magic bytes.

I test superano ma gli attacchi funzionano in produzione. I test con dati hardcoded testano solo i casi che il programmatore ha immaginato. Aggiungi test con payload di injection reali (vedi Step 12), valori limite (stringa vuota, null, array invece di stringa, numero molto grande) e payload di fuzzing. Strumenti come Burp Suite o OWASP ZAP automatizzano questo tipo di testing.

CORS blocca le richieste prima della validazione. Il middleware CORS deve essere dichiarato prima delle route e dei middleware di validazione in Express. Se il preflight CORS restituisce 403, la validazione non viene mai raggiunta e gli errori di validazione non vengono mai restituiti al client. Usa app.use(cors(options)) come primo middleware.

Suggerimenti avanzati

Valida le variabili d'ambiente all'avvio

Usa Zod per validare tutte le variabili d'ambiente all'avvio dell'applicazione. Un errore di configurazione (porta mancante, stringa di connessione malformata, secret JWT troppo corto) viene rilevato immediatamente prima che l'app accetti traffico, invece di causare errori runtime in produzione a ore o giorni dall'avvio.

// config/env.js
const { z } = require('zod');

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url({ message: 'DATABASE_URL deve essere un URL valido' }),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET: minimo 32 caratteri'),
  SESSION_SECRET: z.string().min(32, 'SESSION_SECRET: minimo 32 caratteri'),
  ALLOWED_ORIGINS: z.string()
    .transform(s => s.split(',').map(o => o.trim()))
    .default('http://localhost:3000')
});

const result = EnvSchema.safeParse(process.env);

if (!result.success) {
  console.error('[CONFIG] Variabili d\'ambiente non valide:');
  result.error.issues.forEach(i =>
    console.error(`  ${i.path.join('.')}: ${i.message}`)
  );
  process.exit(1);
}

module.exports = result.data;

Integra validazione e rate limiting

La validazione e il rate limiting sono difese complementari. Gli endpoint che eseguono query al database per validazione asincrona (verifica unicità email, disponibilità username) sono particolarmente vulnerabili: ogni richiesta genera un accesso al database, e un attaccante può inviare migliaia di richieste per esaurire le connessioni disponibili. Applica rate limiting più restrittivo sugli endpoint con validazione asincrona: 5 richieste per 10 minuti per la registrazione, invece delle 100 al minuto delle API generali.

Consulta la guida Rate Limiting in Node.js per configurare express-rate-limit con Redis per ambienti multi-istanza. La combinazione di validazione degli input, rate limiting e HMAC per le API interne copre la maggior parte dei vettori di attacco per un'API REST Node.js in produzione.

Copertura correlata

Guide di sicurezza Node.js

Domande frequenti

Devo usare Zod o Joi nel 2026?

Usa Zod se il tuo progetto usa TypeScript: l'inferenza automatica dei tipi con z.infer elimina la duplicazione tra schemi di validazione e interfacce TypeScript. Usa Joi se lavori con JavaScript puro e hai schemi molto complessi con dipendenze condizionali multiple (Joi.when() annidati). Con 201 milioni di download settimanali contro i 23 milioni di Joi, Zod ha già superato Joi in popolarità nel 2025-2026, trainato dall'adozione di TypeScript nel backend Node.js.

La validazione degli input protegge dalla SQL injection?

La validazione riduce la superficie di attacco rifiutando pattern ovviamente malevoli. Ma non sostituisce le query parametrizzate: anche un input perfettamente valido come l'apostrofo nel cognome O'Brien causa SQL injection in una query concatenata. Usa sempre entrambe le difese insieme: validazione per il formato, query parametrizzate per la sicurezza del database.

Dove eseguo la validazione: nel middleware o nel controller?

Nel middleware, prima del controller. Il controller deve ricevere dati già validati e sanitizzati e concentrarsi solo sulla logica di business. La validazione nel controller mescola responsabilità diverse, rende più difficile il riutilizzo degli schemi e complica il testing separato della logica di validazione.

express-validator o Joi per aggiornare un progetto Express esistente?

express-validator per una migrazione graduale: puoi aggiungere validazione a una route alla volta senza ristrutturare il codice esistente. Ogni route diventa indipendente. Joi o Zod per nuovi moduli o per una refactoring completa: la separazione schema/middleware porta a un codice più leggibile e manutenibile nel lungo periodo.

Come gestisco la validazione con microservizi multipli?

Pubblica gli schemi di validazione come pacchetto npm interno condiviso tra i servizi. Con Zod, lo stesso pacchetto esporta sia lo schema sia il tipo TypeScript inferito. Ogni servizio importa lo schema e lo usa per validare i propri input, garantendo consistenza delle regole di validazione senza duplicazione. Il pacchetto interno diventa la fonte di verità per il contratto API tra i servizi.

Come valido un array di oggetti con express-validator?

Usa la notazione con wildcard *: body('items.*.productId').isUUID() valida il campo productId di ogni elemento dell'array items. Combina con body('items').isArray({ min: 1, max: 50 }) per validare anche la dimensione dell'array. Per strutture annidate complesse con molti livelli, Joi e Zod offrono un'API più leggibile e meno soggetta a errori di sintassi.

Quanto rallenta l'API la validazione con Zod o Joi?

L'overhead per richieste normali è trascurabile. Zod 4 ha introdotto ottimizzazioni significative rispetto a Zod 3, con validazioni sincrone tipiche che completano in meno di 1 ms. L'overhead diventa misurabile solo con array di migliaia di elementi o con validatori asincroni che accedono al database in cascata. In questi casi, separa la validazione sincrona del formato da quella asincrona di unicità, ed applica la seconda solo dopo che la prima ha avuto successo.