L’OWASP Top 10 è la lista delle 10 vulnerabilità web più critiche aggiornata dall’Open Web Application Security Project. La versione 2025, la più recente, cambia l’ordine rispetto al 2021: il Broken Access Control rimane al primo posto con un tasso di incidenza del 3,73% delle applicazioni testate, la Security Misconfiguration sale al secondo posto, e i Software Supply Chain Failures entrano con il più alto tasso medio di incidenza del 5,19% e oltre 215.000 occurrenze rilevate. In questo tutorial implementiamo le difese per tutte e 10 le vulnerabilità in Node.js e Express con codice funzionante, step per step.
Ogni sezione mostra il pattern vulnerabile, poi la correzione con il codice esatto da usare. Nessuna teoria senza codice: ogni vulnerabilità include un esempio realistico di come viene sfruttata e la contromisura specifica. Al termine del tutorial avrai un middleware stack Express production-ready che copre tutte e 10 le categorie OWASP 2025.
OWASP Top 10 2025: Le 10 Vulnerabilità in Ordine di Rischio
La versione 2025 dell’OWASP Top 10 introduce cambiamenti significativi rispetto al 2021. Due nuove categorie fanno il loro ingresso: Software Supply Chain Failures (A03) e Mishandling of Exceptional Conditions (A10). Il Server-Side Request Forgery (SSRF), che era A10 nel 2021 come categoria separata, viene ora assorbito in Broken Access Control e Insecure Design.
| Ranking 2025 | Categoria | Ranking 2021 | Incidenza | Impatto |
|---|---|---|---|---|
| A01:2025 | Broken Access Control | A01:2021 | 3,73% | Critico |
| A02:2025 | Security Misconfiguration | A05:2021 | Alta | Alto |
| A03:2025 | Software Supply Chain Failures | Nuova (5,19%) | 5,19% | Critico |
| A04:2025 | Cryptographic Failures | A02:2021 | Alta | Critico |
| A05:2025 | Injection | A03:2021 | 100% app test | Critico |
| A06:2025 | Insecure Design | A04:2021 | Media | Alto |
| A07:2025 | Authentication Failures | A07:2021 | Alta | Critico |
| A08:2025 | Software and Data Integrity Failures | A08:2021 | Media | Alto |
| A09:2025 | Security Logging and Alerting Failures | A09:2021 | Alta | Medio |
| A10:2025 | Mishandling of Exceptional Conditions | Nuova (24 CWE) | 24 CWE | Alto |
Prerequisiti e Setup del Progetto
Prima di iniziare, verifica di avere installato l’ambiente di sviluppo corretto. Questo tutorial richiede Node.js 18+ e usa Express come framework web. I pacchetti di sicurezza che installeremo sono tutti manutentivi attivamente e aggiornati nel 2025-2026.
| Requisito | Versione | Verifica | Scopo nel tutorial |
|---|---|---|---|
| Node.js | 18.0.0+ | node --version | Runtime JavaScript |
| npm | 9.0.0+ | npm --version | Gestione pacchetti |
| Express | 4.18+ | Package.json | Framework web |
| helmet | 7.x | npm list helmet | Header di sicurezza HTTP |
| express-validator | 7.x | npm list express-validator | Validazione input |
| mysql2 o pg | Ultima | npm list mysql2 | Query parametrizzate |
Crea il progetto e installa le dipendenze di sicurezza:
mkdir owasp-nodejs-demo && cd owasp-nodejs-demo
npm init -y
npm install express helmet express-validator mysql2 pg jsonwebtoken bcrypt
npm install --save-dev nodemon
# Verifica le versioni installate
npm list --depth=0
Step 1-2: A01 Broken Access Control – Il Rischio Numero Uno
Il Broken Access Control è rimasto al primo posto nell’OWASP Top 10 sia nel 2021 che nel 2025, con un tasso di incidenza del 3,73% tra tutte le applicazioni analizzate. In Node.js, il pattern vulnerabile più comune è un’API che usa un parametro dell’URL per identificare la risorsa senza verificare che l’utente autenticato sia il proprietario di quella risorsa.
Step 1: Identificare il pattern vulnerabile (IDOR – Insecure Direct Object Reference).
// VULNERABILE: chiunque con un token valido può leggere qualsiasi fattura
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
if (!invoice) return res.status(404).json({ error: 'Non trovata' });
res.json(invoice); // ERRORE: non verifica che invoice.ownerId === req.user.id
});
// Attacco: un utente malintenzionato prova semplicemente
// GET /api/invoices/1, /api/invoices/2, /api/invoices/3 ecc.
// e legge le fatture di tutti gli utenti
Step 2: Implementare l’autorizzazione a livello di oggetto con middleware riutilizzabili.
// security/access-control.js - Middleware di controllo accessi centralizzato
/**
* Verifica che l'utente autenticato sia proprietario della risorsa.
* @param {Function} loadResource - Funzione che carica la risorsa dato l'ID
* @param {string} ownerField - Campo della risorsa che contiene l'ID del proprietario
*/
function requireOwnership(loadResource, ownerField = 'userId') {
return async (req, res, next) => {
try {
const resource = await loadResource(req.params.id);
if (!resource) return res.status(404).json({ error: 'Risorsa non trovata' });
// Confronto stretto tra l'ID del proprietario e l'ID dell'utente autenticato
if (String(resource[ownerField]) !== String(req.user.id)) {
return res.status(403).json({ error: 'Accesso negato' });
}
req.resource = resource; // Disponibile per il next handler
next();
} catch (err) {
next(err);
}
};
}
/**
* Verifica che l'utente abbia un ruolo specifico.
* @param {...string} roles - Ruoli ammessi
*/
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Autorizzazione insufficiente' });
}
next();
};
}
module.exports = { requireOwnership, requireRole };
// Uso corretto
const { requireOwnership, requireRole } = require('./security/access-control');
app.get('/api/invoices/:id',
requireAuth,
requireOwnership(id => db.invoice.findById(id), 'ownerId'),
(req, res) => {
res.json(req.resource); // Solo la risorsa dell'utente autenticato
}
);
app.delete('/api/users/:id', requireAuth, requireRole('admin'), async (req, res) => {
await db.user.delete(req.params.id);
res.sendStatus(204);
});
Step 3-4: A02 Security Misconfiguration – Header e Configurazione Sicura
La Security Misconfiguration ha scalato dal quinto al secondo posto nell’OWASP Top 10 2025. In Node.js ed Express, le configurazioni errate più frequenti includono: header HTTP di sicurezza assenti, l’header X-Powered-By: Express che rivela il framework in uso, stack trace esposti nei messaggi di errore, variabili d’ambiente con segreti committate nel repository, e endpoint di debug accessibili in produzione.
Step 3: Configurare Helmet.js con Content Security Policy. Helmet aggiunge automaticamente 11 header di sicurezza HTTP. La configurazione di default protegge da clickjacking, MIME sniffing, XSS e altri attacchi, ma la Content Security Policy va personalizzata per la tua applicazione.
// security/helmet-config.js - Configurazione Helmet produzione
const helmet = require('helmet');
function setupSecurityHeaders(app) {
// Rimuovi il fingerprint del framework
app.disable('x-powered-by');
app.use(helmet({
// Content Security Policy: previene XSS e injection di risorse esterne
contentSecurityPolicy: {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Rimuovi unsafe-inline se possibile
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"], // Disabilita plugin (Flash, Java)
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"], // Previene clickjacking
formAction: ["'self'"],
upgradeInsecureRequests: []
}
},
// HTTP Strict Transport Security: forza HTTPS per 1 anno
hsts: {
maxAge: 31536000, // 1 anno in secondi
includeSubDomains: true,
preload: true
},
// Impedisce al browser di "indovinare" il MIME type
noSniff: true,
// Previene il rendering in iframe (clickjacking)
frameguard: { action: 'deny' },
// Cross-Origin policies per isolamento del processo
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-site' },
// Rimuovi header che rivelano info sul server
referrerPolicy: { policy: 'no-referrer' }
}));
}
module.exports = { setupSecurityHeaders };
Step 4: Gestione sicura degli errori senza esporre dettagli interni. I messaggi di errore non devono mai contenere stack trace, query SQL, nomi di file interni o versioni delle librerie. Questa informazione aiuta un attaccante a identificare vulnerabilità specifiche.
// security/error-handler.js - Gestore errori produzione
const IS_PROD = process.env.NODE_ENV === 'production';
/**
* Middleware di gestione errori Express (4 parametri obbligatori).
* Va registrato DOPO tutti gli altri middleware e route.
*/
function secureErrorHandler(err, req, res, next) {
// Log dell'errore completo solo lato server
console.error({
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
error: err.message,
stack: err.stack,
user: req.user?.id
});
// Risposta generica al client: nessun dettaglio interno
const statusCode = err.statusCode || err.status || 500;
const clientMessage = IS_PROD
? 'Si è verificato un errore. Riprova più tardi.'
: err.message; // Solo in sviluppo mostra il messaggio reale
res.status(statusCode).json({
error: clientMessage,
requestId: req.id // Solo per correlazione log, non rivela nulla
});
}
module.exports = { secureErrorHandler };
Step 5: A03 Software Supply Chain Failures – Difendere le Dipendenze
I Software Supply Chain Failures sono la nuova categoria A03 nell’OWASP Top 10 2025, con il più alto tasso medio di incidenza del 5,19% e oltre 215.000 occurrenze documentate nei dati contribuiti. Nel 2024-2025, diversi attacchi di supply chain hanno colpito l’ecosistema npm: pacchetti con script postinstall malevoli, typosquatting (nomi di pacchetti quasi identici a quelli legittimi), e dependency confusion (pacchetti interni sostituiti da quelli pubblici con stesso nome).
// Verifica sicurezza dipendenze durante CI/CD
// package.json - Script di sicurezza
{
"scripts": {
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"install:ci": "npm ci --ignore-scripts --omit=dev",
"check:licenses": "npx license-checker --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC'"
}
}
// .npmrc - Configurazione npm sicura
// audit=true
// fund=false
// ignore-scripts=false # Valuta se ignorare gli script per dipendenze non fidate
// save-exact=true # Versioni esatte invece di range (^1.0.0)
# Pipeline di sicurezza CI/CD (GitHub Actions)
# .github/workflows/security.yml
name: Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm audit --audit-level=high
- run: npx snyk test # Analisi avanzata vulnerabilità (opzionale)
# Usa npm ci invece di npm install in produzione:
# npm ci è deterministico, usa package-lock.json e non modifica node_modules
Misure aggiuntive per la sicurezza della supply chain: verifica l’integrità dei pacchetti con npm audit e Snyk a ogni build; usa Dependabot o Renovate per aggiornamenti automatici delle dipendenze con patch di sicurezza; mantieni il lockfile (package-lock.json) nel repository; considera l’uso di private npm registry per i pacchetti interni, isolandoli dal namespace pubblico.
Step 6: A04 Cryptographic Failures – Proteggere i Dati Sensibili
I Cryptographic Failures (ex “Sensitive Data Exposure” nel 2021) riguardano l’uso di algoritmi crittografici deboli, la mancata cifratura di dati sensibili, e la generazione di token con generatori non crittograficamente sicuri. In Node.js, l’errore più comune è usare Math.random() per generare token di sicurezza: produce numeri pseudo-casuali non sicuri, predicibili da un attaccante che analizza l’output del generatore.
// security/crypto-utils.js - Utilità crittografiche sicure
const crypto = require('crypto');
const bcrypt = require('bcrypt');
// VULNERABILE: Math.random() non è crittograficamente sicuro
// const token = Math.random().toString(36).slice(2); // NON USARE
/**
* Genera un token crittograficamente sicuro.
* @param {number} bytes - Lunghezza in byte (default 32 = 256 bit)
* @returns {string} Token hex sicuro (64 caratteri per 32 byte)
*/
function generateSecureToken(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex');
}
/**
* Genera un OTP numerico a 6 cifre crittograficamente sicuro.
*/
function generateSecureOTP() {
const randomValue = crypto.randomInt(0, 1000000);
return randomValue.toString().padStart(6, '0');
}
/**
* Hash sicuro della password con bcrypt (costo 12).
* Il costo 12 richiede circa 250ms su hardware moderno: sufficiente per limitare brute force.
*/
async function hashPassword(password) {
const BCRYPT_ROUNDS = 12;
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/**
* Confronto sicuro di stringhe (timing-safe): previene timing attacks.
* NON usare === per confrontare token o hash.
*/
function safeCompare(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
module.exports = { generateSecureToken, generateSecureOTP, hashPassword, verifyPassword, safeCompare };
Step 7: A05 Injection – SQL Injection e Prevenzione
L’Injection scende dal terzo al quinto posto nell’OWASP Top 10 2025, ma rimane il tipo di vulnerabilità presente nel 100% delle applicazioni testate nei dati contribuiti. La SQL injection è il caso più noto: concatenare input dell’utente in una query SQL permette a un attaccante di modificare la logica della query, aggirare l’autenticazione, leggere tabelle arbitrarie o eliminare dati. In Node.js, la difesa è semplice ma richiede disciplina: usare sempre query parametrizzate.
// VULNERABILE: concatenazione di stringhe in SQL = SQL Injection
app.get('/users', async (req, res) => {
const email = req.query.email;
// Se email = "' OR '1'='1", restituisce TUTTI gli utenti
const sql = `SELECT * FROM users WHERE email = '${email}'`;
const [rows] = await pool.query(sql);
res.json(rows);
});
// Attacco classico:
// GET /users?email=' OR '1'='1'--
// Restituisce tutti gli utenti, bypassando il filtro
// CORRETTO con mysql2: query parametrizzata con placeholder ?
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10
});
app.get('/users', requireAuth, async (req, res) => {
// Il placeholder ? e i valori separati prevengono SQL injection
const [rows] = await pool.execute(
'SELECT id, email, role FROM users WHERE email = ? AND status = ?',
[req.query.email, 'active'] // Valori passati separatamente, mai nel template SQL
);
res.json(rows);
});
// CORRETTO con pg (PostgreSQL): placeholder $1, $2 ecc.
const { Pool } = require('pg');
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get('/users', requireAuth, async (req, res) => {
const result = await pgPool.query(
'SELECT id, email, role FROM users WHERE email = $1 AND status = $2',
[req.query.email, 'active']
);
res.json(result.rows);
});
// Identificatori dinamici (ORDER BY, nome tabella): whitelist esplicita
const ALLOWED_SORT_COLUMNS = new Set(['created_at', 'email', 'name']);
app.get('/users/list', requireAuth, async (req, res) => {
const sortBy = req.query.sort || 'created_at';
if (!ALLOWED_SORT_COLUMNS.has(sortBy)) {
return res.status(400).json({ error: 'Campo di ordinamento non valido' });
}
// Solo identificatori dalla whitelist - sicuro dalla SQL injection
const [rows] = await pool.execute(
`SELECT id, email FROM users ORDER BY ${sortBy} LIMIT ?`,
[parseInt(req.query.limit) || 20]
);
res.json(rows);
});
Per ulteriori dettagli sulla prevenzione dell’injection in tutti i contesti (NoSQL, LDAP, OS command), consulta la OWASP SQL Injection Prevention Cheat Sheet. La regola fondamentale: non costruire mai query o comandi concatenando input non validato, qualunque sia il tipo di store (SQL, MongoDB, Redis, OS).
Step 8: A04 XSS e Prevenzione – Sanitizzare l’Output
Il Cross-Site Scripting (XSS) non è una categoria separata nell’OWASP Top 10 2025 (era A03:2021 nella versione precedente come parte dell’Injection), ma rimane una delle vulnerabilità più sfruttate nelle web application. In Node.js con Express, il rischio XSS sorge quando l’applicazione riflette input utente non sanitizzato direttamente nell’HTML della risposta, permettendo a un attaccante di iniettare script JavaScript che vengono eseguiti nel browser delle vittime.
// security/xss-defense.js - Difesa da Cross-Site Scripting
const { body, validationResult, query } = require('express-validator');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
// Setup DOMPurify per ambiente Node.js (non ha il DOM nativo)
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
/**
* Sanitizza HTML accettato dall'utente (es. editor rich text).
* Per input di solo testo, NON usare DOMPurify: usa escape e template literal semplici.
*/
function sanitizeHtml(dirtyHtml) {
return DOMPurify.sanitize(dirtyHtml, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['script', 'style', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'href']
});
}
// Middleware di validazione per un form commento
const validateComment = [
body('author').trim().isLength({ min: 1, max: 100 }).escape(), // .escape() per testo puro
body('content').trim().isLength({ min: 1, max: 5000 }), // HTML: sanitize sotto
body('email').isEmail().normalizeEmail(),
query('page').optional().isInt({ min: 1, max: 100 })
];
app.post('/comments',
requireAuth,
validateComment,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Solo se il campo accetta HTML: sanitizza con DOMPurify
const safeContent = sanitizeHtml(req.body.content);
// Per testo puro: .escape() di express-validator o htmlspecialchars-equivalente
await db.comment.create({
author: req.body.author, // Già escapato da .escape()
content: safeContent,
userId: req.user.id
});
res.status(201).json({ success: true });
}
);
module.exports = { sanitizeHtml, validateComment };
Il Content-Security-Policy (CSP) configurato in Helmet al Step 3 fornisce un secondo livello di difesa: anche se del codice XSS raggiunge il browser, la CSP impedisce l’esecuzione di script inline non autorizzati e il caricamento di risorse da domini esterni. Per la guida completa sulla prevenzione XSS, consulta la OWASP XSS Prevention Cheat Sheet.
Step 9: A07 Authentication Failures – JWT Sicuro e Gestione Sessioni
Gli Authentication Failures (A07:2025) coprono una serie di vulnerabilità legate alla gestione dell’identità: uso di jwt.decode() invece di jwt.verify(), algoritmo JWT impostato su none, sessioni che non scadono, password deboli senza rate limiting, e mancata invalidazione dei token al logout. L’errore più frequente in Node.js è decodificare il JWT senza verificarne la firma.
// security/auth-middleware.js - Autenticazione JWT sicura
const jwt = require('jsonwebtoken');
// VULNERABILE: jwt.decode() non verifica la firma
// const user = jwt.decode(token); // NON USARE IN PRODUZIONE
/**
* Middleware di autenticazione JWT sicuro.
* Verifica firma, algoritmo, scadenza, issuer e audience.
*/
function requireAuth(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token mancante' });
}
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ['RS256'], // Solo RS256: rifiuta 'none' e algoritmi deboli
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
clockTolerance: 30 // 30 secondi di tolleranza per differenze di orologio
});
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token scaduto' });
}
return res.status(401).json({ error: 'Token non valido' });
}
}
/**
* Emissione di JWT sicuro con scadenza breve.
*/
function issueAccessToken(userId, role) {
return jwt.sign(
{
sub: String(userId),
role,
jti: require('crypto').randomBytes(8).toString('hex') // JWT ID univoco per revoca
},
process.env.JWT_PRIVATE_KEY,
{
algorithm: 'RS256',
expiresIn: '15m', // Access token breve: 15 minuti
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE
}
);
}
module.exports = { requireAuth, issueAccessToken };
// Rate limiting per prevenire brute force su autenticazione
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // Finestra di 15 minuti
max: 10, // Max 10 tentativi di login per finestra per IP
message: { error: 'Troppi tentativi. Riprova tra 15 minuti.' },
standardHeaders: true,
legacyHeaders: false,
// In produzione: usa un Redis store per persistenza cross-processo
// store: new RedisStore({ client: redisClient })
});
app.post('/auth/login', authLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await db.user.findByEmail(email);
// Confronto costante nel tempo per prevenire timing attacks
const isValid = user && await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
// Stessa risposta per email errata e password errata: no user enumeration
return res.status(401).json({ error: 'Credenziali non valide' });
}
const accessToken = issueAccessToken(user.id, user.role);
res.json({ accessToken });
});
Step 10: A09 Security Logging e A10 Exceptional Conditions
Le Security Logging and Alerting Failures (A09:2025) indicano che l’applicazione non registra eventi di sicurezza critici: tentativi di accesso falliti, violazioni di autorizzazione, modifica di dati privilegiati, o bypass dei controlli di sicurezza. Senza questi log, un attaccante può sondare l’applicazione per giorni senza essere rilevato.
Il Mishandling of Exceptional Conditions (A10:2025) è una categoria nuova che copre il comportamento dell’applicazione in caso di errore: se un servizio di policy o autenticazione è temporaneamente non disponibile e l’applicazione “fallisce aperta” (concede l’accesso in caso di errore), un attaccante può sfruttare questa condizione deliberatamente.
// security/audit-logger.js - Log di sicurezza strutturato
const SECURITY_EVENTS = {
AUTH_FAILURE: 'auth.failure',
AUTH_SUCCESS: 'auth.success',
ACCESS_DENIED: 'access.denied',
ADMIN_ACTION: 'admin.action',
DATA_EXPORT: 'data.export',
PRIVILEGE_ESCALATION: 'privilege.escalation'
};
/**
* Registra un evento di sicurezza in formato strutturato.
* In produzione, indirizzare a SIEM (Splunk, ELK, Datadog).
*/
function auditLog(event, req, details = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
event,
actor: req.user?.id || 'anonymous',
ip: req.ip || req.connection.remoteAddress,
userAgent: req.headers['user-agent'],
path: req.path,
method: req.method,
...details
};
// In sviluppo: console. In produzione: logger strutturato (winston, pino)
console.log(JSON.stringify(logEntry));
}
// Middleware che registra tentativi di accesso non autorizzati
function logAccessDenied(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(body) {
if (res.statusCode === 401 || res.statusCode === 403) {
auditLog(SECURITY_EVENTS.ACCESS_DENIED, req, {
status: res.statusCode,
targetResource: req.path
});
}
return originalJson(body);
};
next();
}
module.exports = { auditLog, logAccessDenied, SECURITY_EVENTS };
// A10: Mishandling of Exceptional Conditions - Fail-Closed Pattern
// VULNERABILE: fail-open - concede l'accesso in caso di errore del servizio di policy
app.get('/feature/premium', async (req, res) => {
try {
const r = await fetch('https://policy-service/check-access');
const { allowed } = await r.json();
if (allowed) return res.json({ feature: 'premium content' });
return res.status(403).json({ error: 'Non autorizzato' });
} catch (err) {
// ERRORE: se il servizio di policy è giù, concede l'accesso
return res.json({ feature: 'premium content' }); // VULNERABILE!
}
});
// CORRETTO: fail-closed - nega l'accesso in caso di errore del servizio di policy
app.get('/feature/premium', requireAuth, async (req, res) => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3 secondi timeout
const r = await fetch('https://policy-service/check-access', {
signal: controller.signal,
headers: { 'X-User-Id': req.user.id }
});
clearTimeout(timeout);
const { allowed } = await r.json();
if (!allowed) return res.status(403).json({ error: 'Non autorizzato' });
res.json({ feature: 'premium content' });
} catch (err) {
// Fail-closed: in caso di errore del servizio di policy, NEGA l'accesso
auditLog('policy_service_failure', req, { error: err.message });
return res.status(503).json({ error: 'Servizio temporaneamente non disponibile' });
}
});
Step 11: A08 Integrità dei Dati e A06 Insecure Design
Le Software and Data Integrity Failures (A08:2025) coprono casi in cui il codice o i dati vengono caricati e eseguiti senza verifica di integrità. In Node.js, il caso più comune è accettare URL da un utente e caricare configurazione o moduli da quell’URL senza validazione. Il caso classico di SSRF (Server-Side Request Forgery) entra qui: un attaccante fornisce un URL interno (come http://169.254.169.254/, il metadata server AWS) e il server lo raggiunge per conto dell’attaccante, esponendo credenziali cloud o servizi interni.
// security/ssrf-defense.js - Protezione da Server-Side Request Forgery
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const BLOCKED_HOSTS = new Set([
'localhost', '127.0.0.1', '0.0.0.0', '::1',
'169.254.169.254', // AWS/GCP metadata service
'100.100.100.200', // Alibaba Cloud metadata service
'metadata.google.internal',
'fd00::ec2:254' // IPv6 metadata
]);
// Blocca range di IP privati con regex semplice (produzione: usare libreria ip)
function isPrivateRange(hostname) {
return (
/^10\./.test(hostname) || // 10.x.x.x
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || // 172.16-31.x.x
/^192\.168\./.test(hostname) // 192.168.x.x
);
}
/**
* Valida un URL prima che il server effettui una richiesta esterna.
* @param {string} rawUrl - URL fornito dall'utente
* @returns {URL} Oggetto URL validato
* @throws {Error} Se l'URL non è sicuro
*/
function validateExternalUrl(rawUrl) {
let url;
try {
url = new URL(rawUrl);
} catch {
throw Object.assign(new Error('URL non valido'), { statusCode: 400 });
}
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
throw Object.assign(new Error('Protocollo non consentito'), { statusCode: 400 });
}
if (BLOCKED_HOSTS.has(url.hostname) || isPrivateRange(url.hostname)) {
throw Object.assign(new Error('Destinazione non consentita'), { statusCode: 403 });
}
return url;
}
// Proxy sicuro con validazione SSRF
app.get('/proxy/fetch', requireAuth, async (req, res) => {
try {
const safeUrl = validateExternalUrl(req.query.url);
const response = await fetch(safeUrl.toString(), {
redirect: 'manual', // Non seguire i redirect: un redirect potrebbe puntare a IP interni
headers: { 'User-Agent': 'MyApp/1.0' },
signal: AbortSignal.timeout(5000)
});
if (response.status >= 300 && response.status < 400) {
return res.status(400).json({ error: 'Redirect non consentito' });
}
const contentType = response.headers.get('content-type') || 'text/plain';
res.setHeader('Content-Type', 'text/plain'); // Forza text/plain: no XSS da contenuto remoto
res.send(await response.text());
} catch (err) {
res.status(err.statusCode || 500).json({ error: err.message });
}
});
module.exports = { validateExternalUrl };
L'Insecure Design (A06:2025) riguarda invece i problemi a livello architetturale: meccanismi di sicurezza non integrati nella progettazione. Un esempio è un sistema di reset password senza rate limiting lato server, che si affida solo al client per limitare i tentativi. La difesa qui è progettuale: aggiungere il rate limiting e il cooldown lato server da subito, non come patch successiva.
Step 12: Middleware Stack Completo per Applicazioni Express Production
L'ultimo step assembla tutte le misure di sicurezza in un middleware stack Express completo. L'ordine dei middleware è importante: la gestione degli errori va in coda, Helmet va prima di tutto il resto, e il rate limiter globale precede le route specifiche.
// app.js - Applicazione Express con security stack completo OWASP Top 10
const express = require('express');
const rateLimit = require('express-rate-limit');
const { setupSecurityHeaders } = require('./security/helmet-config');
const { secureErrorHandler } = require('./security/error-handler');
const { requireAuth } = require('./security/auth-middleware');
const { logAccessDenied } = require('./security/audit-logger');
const app = express();
// 1. HEADER DI SICUREZZA (A02: Security Misconfiguration)
setupSecurityHeaders(app);
// 2. PARSING INPUT CON LIMITI
app.use(express.json({ limit: '10kb' })); // Limita payload JSON: previene DoS
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// 3. RATE LIMITING GLOBALE (A07: Authentication Failures + DoS)
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minuti
max: 200, // Max 200 richieste per IP per finestra
standardHeaders: true,
message: { error: 'Troppe richieste. Riprova tra qualche minuto.' }
}));
// 4. LOG DI SICUREZZA (A09: Security Logging Failures)
app.use(logAccessDenied);
// 5. ROUTE APPLICAZIONE
app.use('/api/auth', require('./routes/auth')); // Con rate limiter specifico per login
app.use('/api/users', requireAuth, require('./routes/users'));
app.use('/api/invoices', requireAuth, require('./routes/invoices'));
// 6. GESTIONE ERRORI SICURA - DEVE ESSERE L'ULTIMO MIDDLEWARE (A02)
app.use(secureErrorHandler);
// Avvio sicuro
const PORT = parseInt(process.env.PORT) || 3000;
app.listen(PORT, '127.0.0.1', () => { // Bind solo su localhost, non 0.0.0.0 se dietro proxy
console.log(`[App] Server avviato su porta ${PORT} (NODE_ENV: ${process.env.NODE_ENV})`);
});
module.exports = app;
Per la guida completa sulle best practice di sicurezza Express in produzione, consulta la documentazione ufficiale Express Security Best Practices e la OWASP Access Control Cheat Sheet.
Errori Comuni e Insidie nell'Implementazione della Sicurezza OWASP
Implementare le difese OWASP Top 10 non basta se si cadono in queste trappole frequenti nei progetti Node.js reali.
Trappola 1: Validare solo lato client. Le validazioni JavaScript nel browser non sono controlli di sicurezza. Un attaccante usa curl o Burp Suite per inviare dati arbitrari direttamente all'API. Ogni validazione di sicurezza va fatta lato server, con express-validator o simili.
Trappola 2: Controllare l'autenticazione ma non l'autorizzazione. Un middleware requireAuth che verifica solo la presenza del token JWT non è sufficiente. Ogni endpoint che restituisce o modifica dati specifici di un utente deve verificare che l'utente autenticato sia il proprietario di quei dati.
Trappola 3: Usare jwt.decode() invece di jwt.verify(). jwt.decode() non verifica la firma del token. Un attaccante può modificare il payload del JWT (ad esempio cambiare il ruolo da user a admin) senza invalidare la decodifica. Usare sempre jwt.verify() con la chiave pubblica e l'algoritmo esplicito.
Trappola 4: Configurare il CORS con origin: '*'. Un CORS aperto (Access-Control-Allow-Origin: *) con credenziali permesse è una misconfiguration grave. Specificare esplicitamente i domini autorizzati: origin: ['https://app.tuodominio.it', 'https://admin.tuodominio.it'].
Trappola 5: Registrare l'intera request/response nei log. Log che includono header di autorizzazione, token, o corpo di richieste con dati personali creano un nuovo vettore di compromissione. Sanitizzare i log prima di scriverli: omettere Authorization, Cookie, password, e token.
Trappola 6: Nessun timeout sulle richieste esterne. Senza timeout, una richiesta a un servizio esterno che non risponde blocca il thread (o la Promise) indefinitamente. Usare sempre AbortSignal.timeout(ms) per le fetch esterne, o impostare timeout nel client HTTP.
Troubleshooting: 8 Problemi Frequenti nella Sicurezza Node.js/Express
1. Helmet blocca risorse legittime dell'applicazione (CSP violation). Il Content-Security-Policy è troppo restrittivo e blocca font, immagini o script da CDN usati dall'app. Soluzione: controlla la console del browser per i messaggi "Content Security Policy violation", identifica la risorsa bloccata e aggiungi la direttiva corretta (es. scriptSrc: ["'self'", 'cdn.jsdelivr.net']). In sviluppo, usa il modo report-only prima di enforcement.
2. Il rate limiter blocca utenti legittimi con molte richieste. Il limite globale è troppo basso per alcune categorie di utenti (API client automatizzati, test di carico). Soluzione: differenzia il rate limit per tipo di endpoint. Le route di autenticazione possono avere un limite più stretto (10 req/15min), le API data un limite più ampio (1000 req/15min per IP autenticato).
3. JsonWebTokenError: invalid signature dopo il deploy. La chiave privata/pubblica RS256 è cambiata tra ambienti o tra riavvii del processo. Soluzione: caricare le chiavi da variabili d'ambiente (process.env.JWT_PRIVATE_KEY) e verificare che siano identiche tra tutti i processi dell'applicazione. Se si usano variabili multilinea, usare \n come separatore e sostituirle al caricamento.
4. SQL injection possibile nonostante le query parametrizzate. Stai usando query parametrizzate per i valori ma concateni identificatori (nomi di colonne, tabelle, ORDER BY) dall'input utente. Soluzione: usa whitelist esplicite per tutti gli identificatori dinamici. I placeholder (? o $1) funzionano solo per i valori, non per i nomi di colonne o tabelle.
5. express-validator non intercetta input malevoli. Hai aggiunto i validatori ma non chiami validationResult(req) nel handler. I validatori di express-validator raccolgono gli errori ma non bloccano la richiesta: devi controllare esplicitamente if (!errors.isEmpty()) return res.status(400)....
6. Errori diversi per email non trovata vs password errata (user enumeration). Rispondendo con "email non trovata" vs "password errata", permetti a un attaccante di enumerare gli account esistenti. Usa sempre la stessa risposta per entrambi i casi: "Credenziali non valide". Il confronto della password deve avvenire anche quando l'utente non esiste (usa un hash fittizio) per evitare timing attacks.
7. Il log di sicurezza include dati sensibili (token, password). Molti logger JSON registrano l'intera request, inclusi gli header. Soluzione: configura il logger per omettere campi sensibili. Con Pino: redact: ['req.headers.authorization', 'req.body.password', 'req.body.token'].
8. SSRF possibile nonostante la validazione dell'hostname. Stai validando l'hostname dell'URL ma non verifichi l'IP a cui risolve il DNS. Un attaccante usa DNS rebinding: il dominio risolve a un IP pubblico durante la validazione e a un IP privato durante la fetch effettiva. Soluzione: risolvi il nome DNS prima di fare la fetch e verifica che l'IP risolto non sia in un range privato. In produzione, usa un egress firewall che blocca il traffico verso IP privati.
Checklist di Sicurezza OWASP per Applicazioni Node.js in Produzione
Usa questa checklist prima di ogni deployment in produzione. Ogni punto corrisponde a una o più categorie OWASP Top 10 2025.
| Categoria OWASP | Controllo | Strumento/Metodo |
|---|---|---|
| A01: Broken Access Control | Ogni endpoint verifica ownership della risorsa | Code review, test di autorizzazione |
| A02: Security Misconfiguration | Helmet configurato, X-Powered-By rimosso, debug disabilitato | npm run helmet-check, curl -I |
| A03: Supply Chain Failures | npm audit clean, lockfile committato, dipendenze aggiornate | npm audit --audit-level=high |
| A04: Cryptographic Failures | Nessun Math.random() per token, bcrypt per password, HTTPS forzato | Code review, HSTS check |
| A05: Injection | Tutte le query usano parametrizzazione, nessuna concatenazione SQL | Code review, sqlmap test |
| A06: Insecure Design | Rate limiting server-side su tutte le operazioni critiche | Load test, code review |
| A07: Auth Failures | jwt.verify() con algoritmo esplicito, scadenza breve, rate limit login | JWT debugger, test di scadenza |
| A08: Data Integrity | URL esterni validati, nessun SSRF, redirect disabilitati | SSRF test, burp suite |
| A09: Logging Failures | Log strutturati per auth failure, access denied, admin actions | Revisione log in staging |
| A10: Exceptional Conditions | Tutti i servizi esterni usano fail-closed, timeout impostati | Chaos test, service shutdown test |
Articoli Correlati
Approfondisci i temi di sicurezza Node.js e crittografia trattati in questo tutorial:
- Rate Limiting in Node.js: API Sicura in 12 Step [2026]
- JWT Authentication in Node.js: 10 Step [2026]
- Protezione CSRF in Node.js: 12 Step [2026]
- bcrypt Password Hashing in Node.js: 11 Step [2026]
- Burp Suite: Test di Sicurezza Web in 12 Step [2026]
- Sicurezza: Guida alle Best Practice di Cybersecurity
Domande Frequenti sull'OWASP Top 10 in Node.js
L'OWASP Top 10 2025 è diverso dal 2021?
Si, in modo significativo. La versione 2025 introduce due nuove categorie: Software Supply Chain Failures (A03, che riflette l'aumento degli attacchi alla catena di fornitura software) e Mishandling of Exceptional Conditions (A10, con 24 CWE correlati). Il SSRF, che era una categoria separata nel 2021 (A10), viene distribuito tra Broken Access Control e Insecure Design nel 2025.
Come posso testare la mia applicazione Node.js per le vulnerabilità OWASP?
Usa una combinazione di strumenti: Burp Suite Community per test manuali di SQL injection e XSS, npm audit per le vulnerabilità nelle dipendenze, OWASP ZAP per scan automatizzati delle applicazioni web, e sqlmap per test approfonditi di SQL injection. Per il codice sorgente, strumenti di SAST (Static Application Security Testing) come Semgrep con le regole OWASP rilevano pattern vulnerabili durante la code review.
Il rate limiting è sufficiente per prevenire gli attacchi di brute force?
Il rate limiting è necessario ma non sufficiente da solo. Un attaccante distribuito che usa migliaia di IP diversi (botnet) può aggirare i limiti per IP. Le misure complementari includono: CAPTCHA dopo N tentativi falliti, blocco temporaneo dell'account dopo 5-10 tentativi, notifica all'utente di tentativi sospetti, e monitoraggio delle anomalie di accesso con alerting.
Devo usare un ORM per prevenire la SQL injection?
Un ORM come Sequelize, Prisma o TypeORM usa query parametrizzate di default, riducendo il rischio di SQL injection. Ma non elimina completamente il rischio: ORM che supportano query raw (sequelize.query(), prisma.$queryRaw) possono essere vulnerabili se si concatenano input utente. La regola rimane: mai concatenare input utente in query SQL, ORM o no.
Helmet.js è sufficiente per la sicurezza degli header HTTP?
Helmet configura gli header di sicurezza più importanti in modo rapido. Ma la Content Security Policy va personalizzata per la tua applicazione specifica: i default di Helmet potrebbero essere troppo restrittivi (bloccando risorse legittime) o insufficienti per scenari particolari. Verifica sempre la CSP con il report-only mode prima di abilitarla in enforcement.
Come gestisco i segreti dell'applicazione (chiavi API, password DB) in Node.js?
Non committare mai segreti nel repository. Usa variabili d'ambiente caricate da .env in sviluppo (con il file .env nel .gitignore) e da secret manager in produzione (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Kubernetes Secrets). Verifica periodicamente il repository con strumenti come git-secrets o trufflehog per rilevare segreti committati accidentalmente.
Quanto spesso viene aggiornato l'OWASP Top 10?
L'OWASP Top 10 viene aggiornato ogni 3-4 anni, con versioni significative nel 2013, 2017, 2021, e ora 2025. Tra un'edizione e l'altra, OWASP pubblica cheat sheet e guide aggiornate più frequentemente. Per rimanere aggiornato, segui il blog OWASP e iscriviti alla mailing list del progetto.




