Mentre leggete, dei programmi stanno bussando alle porte del vostro sito. Non è una figura retorica: è il rumore di fondo del web, e chiunque abbia messo online un server e guardato i log di accesso lo ha visto.
La reazione istintiva — «tanto quella pagina non è collegata da nessuna parte» — è precisamente il presupposto sbagliato da cui nasce l'intera categoria di problemi.
Come si presenta dall'altra parte#
# Come si presenta una scansione, dall'altra parte. Non serve un attaccante# esperto: sono due comandi e una lista pubblica di percorsi comuni.ffuf -u https://esempio.it/FUZZ -w seclists/Discovery/Web-Content/raft-medium-directories.txt -mc 200,301,403.git [Status: 301, Size: 178]backup [Status: 403, Size: 199]admin [Status: 200, Size: 4821] <-- pannello raggiungibile.env [Status: 200, Size: 612] <-- credenzialiphpinfo.php [Status: 200, Size: 68204]# Tempo impiegato: 40 secondi. Competenza richiesta: nessuna.Questa è la parte facile da capire e da chiudere: non lasciare online quello che non deve esserci, e verificarlo dopo ogni deploy. Un backup dimenticato, un phpinfo.php di sei mesi fa, una cartella .git che espone l'intera storia del progetto — sono errori di igiene, e si risolvono con l'igiene.
La parte difficile è un'altra, e riguarda le pagine che devono esistere.
La falla numero uno: l'autorizzazione sull'oggetto#
Nella classifica OWASP dedicata alle API, il primo posto non è occupato dall'autenticazione ma dalla Broken Object Level Authorization — il vecchio IDOR. La dinamica è sempre la stessa: l'endpoint controlla che siate autenticati, e poi vi consegna la risorsa che avete chiesto senza domandarsi se sia vostra.
// ✗ La vulnerabilità più diffusa nelle API, e la più banale: l'endpoint// verifica CHI SEI ma non SE QUELLA COSA È TUA.app.get('/api/fatture/:id', autenticato, async (req, res) => { const fattura = await db.fattura(req.params.id); res.json(fattura); // ← /api/fatture/1042 restituisce la fattura}); // di chiunque, a chiunque abbia un account// ✗ Anche così è sbagliato, e sembra corretto: due query, e la finestra// fra loro. Peggio: se il controllo cade in un ramo, si dimentica.app.get('/api/fatture/:id', autenticato, async (req, res) => { const fattura = await db.fattura(req.params.id); if (fattura.utenteId !== req.utente.id) return res.status(404).end(); res.json(fattura);});// ✓ Il proprietario entra nella QUERY, non in un controllo dopo la query.// Non c'è modo di dimenticarsene: senza il vincolo, la riga non esce.app.get('/api/fatture/:id', autenticato, async (req, res) => { const fattura = await db.get( 'SELECT * FROM fatture WHERE id = ? AND utente_id = ?', [req.params.id, req.utente.id] ); if (!fattura) return res.status(404).end(); res.json(fattura);});Il secondo blocco merita attenzione perché sembra corretto, ed è quello che si trova nella maggior parte delle revisioni. Il controllo c'è. Il problema è che è separato dalla lettura, e tutto ciò che è separato può essere dimenticato: nel nuovo endpoint scritto in fretta, nel ramo aggiunto per un caso particolare, nella funzione che qualcuno riusa senza sapere che il controllo era altrove.
La terza forma non ha questa fragilità. Il proprietario è parte della WHERE: se il vincolo non c'è, la riga non esce e il bug si manifesta come «non trovo la mia fattura» — un errore visibile subito, non una fuga silenziosa.
Renderlo impossibile da dimenticare#
// Il modo per non poter sbagliare: rendere impossibile scrivere una query// senza perimetro. Il repository non espone MAI un metodo senza contesto.class Fatture { constructor(db, utente) { this.db = db; this.utente = utente; // il perimetro è nel costruttore } perId(id) { return this.db.get( 'SELECT * FROM fatture WHERE id = ? AND utente_id = ?', [id, this.utente.id] ); } elenco({ da, a }) { return this.db.all( 'SELECT * FROM fatture WHERE utente_id = ? AND data BETWEEN ? AND ?', [this.utente.id, da, a] ); }}// Nel gestore della richiesta non esiste più un modo di interrogare le// fatture "in generale". Il collega che aggiungerà un metodo fra sei mesi// erediterà il vincolo senza doverlo sapere.const fatture = new Fatture(db, req.utente);Il salto di qualità è passare da «ricordarsi di aggiungere il vincolo» a «non avere un modo di scrivere una query senza vincolo». Se l'unico accesso alle fatture è un oggetto costruito attorno all'utente corrente, il perimetro non è più una disciplina personale: è una proprietà del codice, che il collega di domani eredita senza doverla conoscere.
Seconda famiglia: l'autorizzazione a livello di funzione#
// Seconda famiglia: l'autorizzazione a livello di FUNZIONE.// L'interfaccia non mostra il pulsante "elimina" agli utenti normali,// e qualcuno conclude che l'endpoint sia protetto.app.delete('/api/utenti/:id', autenticato, async (req, res) => { await db.eliminaUtente(req.params.id); // chiunque, con un account res.status(204).end(); // e curl, cancella chiunque});// ✓ Il controllo dei permessi sta sul server, su OGNI rotta, e non// dipende da come ci si è arrivati. Un middleware esplicito è meglio// di un controllo dentro il gestore: si vede nell'elenco delle rotte.app.delete('/api/utenti/:id', autenticato, richiede('utenti:elimina'), gestore);// E la rotta senza middleware di autorizzazione deve essere un errore,// non una svista: un test che attraversa il router e fallisce se trova// una rotta priva di controllo costa venti righe e non dimentica mai.Qui il ragionamento sbagliato è ancora più esplicito: «il pulsante non c'è, quindi la funzione non è raggiungibile». Ma l'interfaccia è una cortesia verso l'utente, non un confine di sicurezza. Il confine è il server, e vale per ogni richiesta che gli arriva — che provenga dal vostro frontend, da curl, o da uno script che qualcuno ha scritto guardando le richieste di rete della vostra pagina.
L'accorgimento operativo che ripaga: un test che attraversa la tabella delle rotte e fallisce se ne trova una priva di middleware di autorizzazione, con un elenco esplicito di eccezioni. Venti righe, e la rotta dimenticata diventa un test rosso invece che un incidente.
Terza famiglia: l'assegnazione di massa#
// Terza famiglia, cugina delle prime due: l'assegnazione di massa.// L'endpoint di modifica del profilo accetta l'oggetto così com'è.app.patch('/api/profilo', autenticato, async (req, res) => { await db.aggiornaUtente(req.utente.id, req.body); // ← tutto il corpo res.json({ ok: true });});// L'utente manda { "nome": "Mario", "ruolo": "admin", "creditoResiduo": 99999 }// e i campi che l'interfaccia non mostrava vengono scritti lo stesso.// ✓ Lista di permessi sui campi, non di divieti. Quello che non è previsto// non passa — compreso il campo che verrà aggiunto alla tabella domani.const MODIFICABILI = ['nome', 'cognome', 'telefono', 'lingua'];app.patch('/api/profilo', autenticato, async (req, res) => { const modifiche = {}; for (const campo of MODIFICABILI) { if (campo in req.body) modifiche[campo] = req.body[campo]; } await db.aggiornaUtente(req.utente.id, modifiche); res.json({ ok: true });});È la stessa struttura logica delle altre due, applicata ai campi invece che alle risorse: il server accetta più di quanto l'interfaccia offra. E come per il path traversal, la difesa corretta è una lista di permessi: elencare i campi modificabili, non quelli vietati. Un elenco di divieti va aggiornato ogni volta che si aggiunge una colonna, e nessuno se ne ricorda.
Gli identificatori opachi non sono autorizzazione#
// "Usiamo gli UUID così non si possono indovinare."// Gli identificatori non sequenziali sono utili, ma NON sono autorizzazione.//// Un identificatore opaco riduce l'enumerazione a tappeto. Non protegge da:// - un identificatore visto una volta e conservato (un ex dipendente,// un collaboratore a cui il documento era stato condiviso);// - un identificatore che compare in un log, in un Referer, in un// ticket di assistenza, nella cronologia del browser;// - un identificatore restituito da un ALTRO endpoint meno protetto.//// UUID v4 sì (casuale). UUID v1 no: contiene il timestamp e l'indirizzo// MAC, quindi è parzialmente prevedibile.const id = crypto.randomUUID(); // v4, dal CSPRNG del sistemaPassare da identificatori sequenziali a UUID è un miglioramento reale: toglie la possibilità di enumerare l'intero archivio contando da 1. Ma è una riduzione della superficie, non un controllo di accesso. Un identificatore che finisce in un log, in un'intestazione Referer, in un ticket di assistenza o nelle mani di un ex collaboratore resta valido per sempre — a meno che qualcuno, sul server, non verifichi chi lo sta usando.
404 o 403#
// 404 o 403? La scelta rivela o nasconde l'esistenza della risorsa.//// 403 "esiste, ma non è tua" → conferma l'esistenza a chi enumera// 404 "non c'è" → non conferma niente//// Regola pratica: 404 quando la sola esistenza è un'informazione// (fatture, documenti, profili privati); 403 quando l'esistenza è// ovvia e negare confonderebbe l'utente legittimo (/admin su un// gestionale in cui tutti sanno che il pannello esiste).È una scelta di progetto, non una preferenza estetica. Rispondere 403 a «questa fattura non è tua» conferma all'attaccante che la fattura 1042 esiste, e gli permette di mappare l'archivio anche senza leggerlo. Dove la sola esistenza di una risorsa è un'informazione sensibile, la risposta corretta è 404.
Il test che trova quasi tutto in dieci minuti#
# Il test minimo, da fare con due account veri creati apposta.# Serve un token di A e un token di B: nessun endpoint deve mai# rispondere 200 con dati dell'altro.A=$(curl -s -X POST /login -d '{"email":"a@test.it",...}' | jq -r .token)B=$(curl -s -X POST /login -d '{"email":"b@test.it",...}' | jq -r .token)# 1. Risorsa di A, letta con il token di Bcurl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $B" /api/fatture/1042404# 2. Rotta amministrativa con un token normalecurl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $B" -X DELETE /api/utenti/7403# 3. Campo non previsto in una modifica di profilocurl -s -H "Authorization: Bearer $B" -X PATCH /api/profilo -d '{"ruolo":"admin"}'curl -s -H "Authorization: Bearer $B" /api/profilo | jq .ruolo"utente"# 4. Nessun tokencurl -s -o /dev/null -w '%{http_code}\n' /api/fatture/1042401| Controllo | Domanda a cui risponde | Esito atteso |
|---|---|---|
| Risorsa di A con il token di B | l'autorizzazione è sull'oggetto? | 404 |
| Rotta amministrativa con token normale | l'autorizzazione è sulla funzione? | 403 |
Campo non previsto in PATCH | c'è assegnazione di massa? | campo ignorato |
| Richiesta senza token | l'autenticazione c'è? | 401 |
| Token scaduto | la scadenza viene verificata? | 401 |
id di un altro tenant | il perimetro multi-cliente regge? | 404 |
| Identificatore di una risorsa eliminata | la cancellazione è effettiva? | 404 |
Igiene: quello che non deve essere lì#
Tornando alla parte facile, che resta la più produttiva per chi scansiona. Un controllo periodico su ciò che il sito espone davvero — fatto con curl, che vede esattamente quello che vede un bot, senza eseguire JavaScript — trova in pochi secondi:
- cartelle
.git, file.env,.DS_Store,composer.json,package.json; - backup con nomi prevedibili:
backup.zip,dump.sql,sito.tar.gz,index.php.bak; - sorgenti non compilati serviti accanto al bundle, e source map in produzione (vedi proteggere il codice sorgente);
- elenco delle directory abilitato su una cartella di caricamenti;
- ambienti di prova raggiungibili dall'esterno:
staging.,dev.,test., spesso con dati veri e senza limiti.
Non è penetration testing: è manutenzione. Il penetration testing vero comincia dove finisce questa lista, e serve a trovare i punti in cui il server si è fidato dell'interfaccia — cioè esattamente le tre famiglie discusse sopra, che nessuno scanner automatico può verificare al posto vostro, perché richiedono di sapere che cosa dovrebbe essere permesso a chi.