Apri la OWASP Top 10, trovi dieci nomi in inglese e ti chiedi che cosa devi cambiare, lunedì, nel codice che hai davanti. La risposta breve è che la lista non è un elenco di dieci bug: è una mappa di famiglie di debolezze, e ognuna raccoglie decine di CWE, i codici con cui l'industria dà un nome a una debolezza precisa. L'edizione vigente è la 2025; in questa guida la leggi come mappa, e tre dei suoi punti li vedi rompersi e ripararsi su un'applicazione di prova.
Che cos'è la lista e come si legge
La pagina ufficiale la definisce un documento di consapevolezza per chi sviluppa applicazioni web, frutto di un consenso ampio sui rischi più gravi. Non è uno standard di conformità: dice dove guardare, non quando puoi smettere di guardare. Per avere requisiti che si possono verificare uno per uno esiste l'Application Security Verification Standard, giunto alla versione 5.0.0 (pubblicata il 30 maggio 2025), che è il documento da mettere in un capitolato o in una checklist di revisione.
Il metodo è dichiarato nell'introduzione: dodici categorie sono state ordinate in base ai dati inviati da chi fa test di sicurezza, e due sono state promosse dal sondaggio fra la comunità, perché i dati guardano al passato mentre i ricercatori trovano già i problemi di domani. Ogni categoria è un contenitore con un tetto di 40 CWE. Il punto pratico è questo: «Injection» contiene sia il cross-site scripting (CWE-79, molto frequente e di impatto basso) sia la SQL injection (CWE-89, rara e di impatto alto), e la pagina riporta più di 30.000 CVE per il primo e più di 14.000 per la seconda. Una categoria, quindi, non è una difesa sola, ed è per questo che la serie le affronta in articoli separati.
Che cosa è cambiato rispetto al 2021
Nel 2017 al primo posto c'erano le iniezioni; dal 2021 c'è il controllo degli accessi, e nel 2025 ci resta: due edizioni di fila, non quattro. La tabella riassume le posizioni, con il numero di CWE mappati letto nella tabella dei punteggi di ciascuna pagina.
| Posizione | Categoria 2025 | Rispetto al 2021 | CWE mappati |
|---|---|---|---|
| A01 | Broken Access Control | Resta primo; assorbe la SSRF (CWE-918) | 40 |
| A02 | Security Misconfiguration | Da quinto a secondo | 16 |
| A03 | Software Supply Chain Failures | Nuova: allarga Vulnerable and Outdated Components (A06:2021) | 6 |
| A04 | Cryptographic Failures | Da secondo a quarto | 32 |
| A05 | Injection | Da terzo a quinto | 37 |
| A06 | Insecure Design | Da quarto a sesto | 39 |
| A07 | Authentication Failures | Resta settimo; perde «Identification» dal nome | 36 |
| A08 | Software or Data Integrity Failures | Resta ottavo | 14 |
| A09 | Security Logging and Alerting Failures | Resta nono; «Alerting» sostituisce «Monitoring» | 5 |
| A10 | Mishandling of Exceptional Conditions | Nuova | 24 |
Due dettagli di lettura. Primo, le pagine di OWASP non sono coerenti fra loro su alcuni conteggi: l'introduzione parla di 5 CWE per A03 e di 38 per A05, mentre la tabella dei punteggi dice 6 e 37, e la stessa pagina di A03 riporta un'incidenza media di 5,19% nel testo e di 5,72% nella tabella. Per questo non uso quelle cifre come argomento: ti dicono la forma dei dati, non una misura da citare. Secondo, A03 ha pochissimi casi nei dati (11 CVE collegati), ma i punteggi medi di sfruttabilità e impatto più alti della lista, e il 50% di chi ha risposto al sondaggio l'ha messa al primo posto: la difficoltà di misurarla non la rende meno grave.
Un esempio di che cosa significhi A03 nella pratica: il 23 settembre 2025 la CISA, l'agenzia statunitense per la sicurezza informatica, ha pubblicato un avviso su un verme auto-replicante, noto come «Shai-Hulud», che aveva compromesso oltre 500 pacchetti del registro npm rubando credenziali dagli ambienti degli sviluppatori e usandole per pubblicare versioni infette di altri pacchetti. Come difendere le dipendenze lo trovi nell'articolo 06.
Tre difetti dimostrati su un'applicazione di prova
Gli esempi che seguono girano su applicazioni scritte apposta, con dati finti (indirizzi example.test), eseguite dentro il processo con inject() di Fastify 5.12.8 (la versione sul registro npm il 13 novembre 2026; si installa con npm install fastify@5.12.8). Il metodo, descritto nella guida ai test di Fastify, simula una richiesta HTTP senza aprire una porta e senza avviare un server: nessuna richiesta esce dalla macchina. L'esecuzione è su Node 24.11.0. Gli script sono due volte identici nell'uscita.
A01: leggere il profilo di un altro (IDOR)
L'insecure direct object reference (CWE-639, nella famiglia A01) nasce quando l'identificativo di una risorsa arriva dal client e il server non chiede se quel client ha diritto di vederla. Nello script l'intestazione x-utente simula un utente già autenticato (nell'articolo 04 costruiamo l'autenticazione vera). La rotta /profilo/:id gira due volte: senza il controllo e con il controllo, che è la riga con req.utente.id !== id.
import Fastify from 'fastify';
const utenti = new Map([
['1', { id: '1', nome: 'Alice', email: 'alice@example.test', ruolo: 'user' }],
['2', { id: '2', nome: 'Bruno', email: 'bruno@example.test', ruolo: 'user' }],
['9', { id: '9', nome: 'Admin', email: 'admin@example.test', ruolo: 'admin' }],
]);
// Autenticazione simulata: l'intestazione x-utente porta l'id di chi ha fatto login.
// In un'applicazione vera qui c'è la verifica di una sessione (vedi l'articolo 04).
function costruisci({ controllaProprietario }) {
const app = Fastify();
app.addHook('onRequest', async (req, reply) => {
req.utente = utenti.get(req.headers['x-utente']);
if (!req.utente) return reply.code(401).send({ errore: 'non autenticato' });
});
app.get('/profilo/:id', async (req, reply) => {
const { id } = req.params;
if (controllaProprietario && req.utente.id !== id && req.utente.ruolo !== 'admin') {
return reply.code(403).send({ errore: 'vietato' });
}
const trovato = utenti.get(id);
return trovato ? trovato : reply.code(404).send({ errore: 'non trovato' });
});
return app;
}
for (const controllaProprietario of [false, true]) {
const app = costruisci({ controllaProprietario });
console.log(controllaProprietario ? '--- con controllo del proprietario' : '--- senza controllo');
for (const [chi, cosa] of [['1', '1'], ['1', '2'], ['9', '2']]) {
const r = await app.inject({ url: `/profilo/${cosa}`, headers: { 'x-utente': chi } });
console.log(`utente ${chi} chiede il profilo ${cosa}: ${r.statusCode} ${r.body}`);
}
}
// Output:
// --- senza controllo
// utente 1 chiede il profilo 1: 200 {"id":"1","nome":"Alice","email":"alice@example.test","ruolo":"user"}
// utente 1 chiede il profilo 2: 200 {"id":"2","nome":"Bruno","email":"bruno@example.test","ruolo":"user"}
// utente 9 chiede il profilo 2: 200 {"id":"2","nome":"Bruno","email":"bruno@example.test","ruolo":"user"}
// --- con controllo del proprietario
// utente 1 chiede il profilo 1: 200 {"id":"1","nome":"Alice","email":"alice@example.test","ruolo":"user"}
// utente 1 chiede il profilo 2: 403 {"errore":"vietato"}
// utente 9 chiede il profilo 2: 200 {"id":"2","nome":"Bruno","email":"bruno@example.test","ruolo":"user"}
Senza controllo, Alice (utente 1) legge il profilo di Bruno cambiando un numero nell'indirizzo: nessun attacco sofisticato, solo l'URL. Con il controllo la stessa richiesta ottiene 403, mentre l'amministratore, che per ruolo può leggere tutto, passa. Una scelta da conoscere: rispondere 403 conferma che il profilo 2 esiste; se l'esistenza stessa è un'informazione sensibile, rispondi 404 in entrambi i casi. Il difetto tipico non è dimenticare il controllo in un punto, ma ricordarlo in nove rotte su dieci: per questo OWASP raccomanda di scrivere il controllo una volta e riusarlo, negando tutto per impostazione predefinita. In Angular una canActivate nasconde la pagina, ma l'API resta raggiungibile con qualsiasi client: la regola vera vive sul server.
A10: quando l'errore apre la porta
La categoria nuova del 2025 raccoglie i comportamenti anomali gestiti male, fra cui CWE-234 (parametro mancante non gestito), CWE-476 (dereferenziazione nulla) e CWE-636, il «fallire in modo aperto». Lo script ha una funzione verifica che, se l'intestazione authorization manca, va in TypeError sulla riga con split: una condizione eccezionale vera, causata da chi invia una richiesta senza intestazione. Cambia solo che cosa fa il catch.
import Fastify from 'fastify';
// Verifica volutamente minimale: il token valido è la stringa "token-di-alice".
function verifica(intestazione) {
const token = intestazione.split(' ')[1]; // TypeError se l'intestazione manca
if (token !== 'token-di-alice') throw new Error('token non valido');
return { id: '1' };
}
function costruisci(chiusoInCasoDiErrore) {
const app = Fastify();
app.addHook('onRequest', async (req, reply) => {
try {
req.utente = verifica(req.headers.authorization);
} catch (e) {
if (chiusoInCasoDiErrore) return reply.code(401).send({ errore: 'autenticazione fallita' });
// fail-open: l'errore viene "gestito" e la richiesta prosegue
}
});
app.get('/riservato', async (req) => ({ visto_da: req.utente ? req.utente.id : 'nessuno' }));
return app;
}
const casi = [
['nessuna intestazione', {}],
['token sbagliato', { authorization: 'Bearer x' }],
['token giusto', { authorization: 'Bearer token-di-alice' }],
];
for (const chiuso of [false, true]) {
const app = costruisci(chiuso);
console.log(chiuso ? '--- fail-closed' : '--- fail-open');
for (const [nome, headers] of casi) {
const r = await app.inject({ url: '/riservato', headers });
console.log(`${nome}: ${r.statusCode} ${r.body}`);
}
}
// Output:
// --- fail-open
// nessuna intestazione: 200 {"visto_da":"nessuno"}
// token sbagliato: 200 {"visto_da":"nessuno"}
// token giusto: 200 {"visto_da":"1"}
// --- fail-closed
// nessuna intestazione: 401 {"errore":"autenticazione fallita"}
// token sbagliato: 401 {"errore":"autenticazione fallita"}
// token giusto: 200 {"visto_da":"1"}
Nella versione fail-open l'eccezione viene inghiottita e la richiesta prosegue: senza intestazione e con un token sbagliato ottieni 200 sulla rotta riservata, con visto_da: nessuno, cioè l'applicazione risponde a un anonimo. Nella versione fail-closed il catch risponde 401 e interrompe. Il fail-open non richiede quasi mai codice scritto apposta: basta un catch che registra e va avanti, ed è un difetto che i test con un token valido non vedono mai, perché provano solo il caso felice. Prova sempre il caso senza intestazione, quello con intestazione malformata e quello con credenziale scaduta.
A10 e A09: l'errore che racconta troppo
CWE-209 descrive i messaggi di errore che contengono informazioni sensibili. Lo script lancia un errore con dentro una query SQL e una stringa di connessione finta (segreto-di-prova); il gestore degli errori ha due modi di rispondere, e lo script controlla con tre includes che cosa finisce nel corpo.
import Fastify from 'fastify';
function costruisci(produzione) {
const app = Fastify({ logger: false });
app.get('/ordini', async () => {
const e = new Error('SQLITE_ERROR: no such column "totale" in: SELECT totale FROM ordini');
e.dsn = 'postgres://app:segreto-di-prova@db.interno:5432/shop';
throw e;
});
app.setErrorHandler((err, req, reply) => {
if (!produzione) {
// dannoso: tutto ciò che sa l'applicazione finisce nella risposta
return reply.code(500).send({ message: err.message, stack: err.stack, dsn: err.dsn });
}
const rif = `E-${String(req.id).replace(/\D/g, '') || '0'}`;
console.log(`[log interno] ${rif} ${err.message}`); // i dettagli restano nel log
return reply.code(500).send({ errore: 'Errore interno', riferimento: rif });
});
return app;
}
for (const produzione of [false, true]) {
const r = await costruisci(produzione).inject({ url: '/ordini' });
const corpo = r.json();
console.log(produzione ? '--- risposta corretta' : '--- risposta che perde dati');
console.log('stato:', r.statusCode, '| chiavi:', Object.keys(corpo).join(','));
console.log('contiene SQL:', r.body.includes('SELECT'), '| contiene credenziali:', r.body.includes('segreto-di-prova'), '| contiene stack:', r.body.includes(' at '));
}
// Output:
// --- risposta che perde dati
// stato: 500 | chiavi: message,stack,dsn
// contiene SQL: true | contiene credenziali: true | contiene stack: true
// [log interno] E-1 SQLITE_ERROR: no such column "totale" in: SELECT totale FROM ordini
// --- risposta corretta
// stato: 500 | chiavi: errore,riferimento
// contiene SQL: false | contiene credenziali: false | contiene stack: false
La versione dannosa restituisce messaggio, stack trace e credenziali: un attaccante ricostruisce nomi di tabelle e colonne e trova il server del database. La versione corretta risponde con un testo generico e un riferimento (E-1) che il supporto può cercare nel log interno. Attenzione al log: scrivere nel log l'intero oggetto dell'errore riporterebbe di nuovo la stringa di connessione nel file; CWE-532 (dati sensibili nei log) è nella famiglia A09, e per questo lo script registra solo il messaggio. Lo stesso A09 aggiunge che un log senza qualcuno che lo guardi non protegge: il nome è cambiato in «Alerting» proprio per dire che l'allarme conta quanto la scrittura.
Dove va il resto della lista nella serie
Gli altri sei argomenti hanno un articolo ciascuno, più spazio di quello che un riepilogo può dare. La corrispondenza è questa:
- XSS, CSRF e intestazioni di sicurezza (famiglie A05 e A01): articolo 02.
- SQL injection e validazione dell'input (A05): articolo 03.
- Sessioni, cookie e credenziali (A07): articolo 04.
- OAuth, JWT e limitazione delle richieste (A01, A07): articolo 05.
- Dipendenze e SBOM (A03): articolo 06.
- Hash, cifratura e token (A04): articolo 07.
Nota che CSRF (CWE-352) sta in A01 e non in A05: per OWASP è un difetto di controllo degli accessi, perché il server accetta un'azione senza sapere se l'utente l'ha voluta. Chiude il percorso l'articolo 08, su come portare questi controlli nella pipeline di consegna. Restano fuori dalla serie, per scelta, la configurazione dell'infrastruttura (A02) e la progettazione sicura (A06), che hanno bisogno di un sistema intero più che di uno script.
Domande frequenti
La OWASP Top 10 basta per dire che un'applicazione è sicura?
No. È un documento di consapevolezza: dice quali famiglie di debolezze considerare, non quali controlli devono esserci. Per requisiti verificabili si usa l'ASVS 5.0.0, che elenca controlli numerati ai quali un revisore può rispondere sì o no.
Quali sono le categorie nuove del 2025?
Due: A03, Software Supply Chain Failures, che allarga la vecchia categoria sulle componenti vulnerabili a tutta la catena di build e distribuzione, e A10, Mishandling of Exceptional Conditions, sulla gestione sbagliata di errori e condizioni anomale. In più la SSRF è confluita in A01.
Perché le contromisure di questi esempi non bastano in produzione?
Perché sono volutamente minime: l'autenticazione è simulata, i dati sono finti e manca tutto ciò che riguarda l'infrastruttura. Servono a mostrare il meccanismo del difetto e la forma della correzione, non a sostituire una revisione del tuo sistema.
Che cosa sai fare adesso
Sai leggere la Top 10 2025 come una mappa di CWE e non come dieci bug, sai che cosa è cambiato rispetto al 2021 e dove i numeri di OWASP non coincidono fra loro. Sai riprodurre su un'applicazione di prova un accesso non autorizzato, un controllo di autenticazione che si apre in caso di errore e una risposta d'errore che consegna credenziali, e sai scrivere la correzione e la prova che regge. Il prossimo passo è il frontend: cosa il browser fa per te e cosa no.
Fonti
- OWASP Top 10:2025 e le dieci schede da A01 a A10 - ordine, nomi, CWE mappati, tabelle dei punteggi, novità del 2025 e metodo (pagine scaricate e lette per intero), consultate il 2026-11-13.
- OWASP Application Security Verification Standard - versione 5.0.0 pubblicata il 30 maggio 2025, consultata il 2026-11-13.
- Widespread Supply Chain Compromise Impacting npm Ecosystem (CISA, 23 settembre 2025) - oltre 500 pacchetti compromessi, funzionamento del verme, consultata il 2026-11-13.
- Fastify: Testing - metodo
inject()e simulazione di richieste HTTP senza server, consultata il 2026-11-13. - Versione di Fastify:
npm view fastify version(5.12.8), 2026-11-13. I tre script sono stati eseguiti connode24.11.0, due volte, con uscita identica.

Commenti
Caricamento commenti...
Accedi per commentare