C'è un modo rapido per capire come un servizio tratta la sicurezza dei suoi utenti: chiedere il recupero della password. Se arriva una email con la vecchia password, il servizio la conserva in una forma leggibile. Se arriva un link per impostarne una nuova, probabilmente no.
È un errore ancora comune, e la parte interessante non è che sia grave — lo è, e si sa — ma che abbia gradazioni intermedie che sembrano compromessi ragionevoli e non lo sono.
Le quattro forme del problema#
// Le quattro forme in cui una password finisce "in chiaro", in ordine di// quanto bene sono travestite. Le prime tre sono equivalenti: chi legge il// database legge le password.// 1. In chiaro, senza giri di parole.INSERT INTO utenti (email, password) VALUES ('a@b.it', 'estate2024');// 2. Codificata. Non è crittografia: è un alfabeto diverso.INSERT INTO utenti (email, password) VALUES ('a@b.it', 'ZXN0YXRlMjAyNA==');// 3. Cifrata con una chiave che sta sullo stesso server.// Chi ruba il database ruba anche .env, o il codice che lo legge.INSERT INTO utenti (email, password) VALUES ('a@b.it', 'aes256:9f2c...');// 4. Hash veloce senza sale. Reversibile in pratica: l'hash di "estate2024"// sta già in una tabella precalcolata pubblica.INSERT INTO utenti (email, password) VALUES ('a@b.it', md5('estate2024'));Le prime due non hanno bisogno di commento. Vale la pena soffermarsi sulla terza, perché è quella che viene presentata come soluzione di compromesso: «non le teniamo in chiaro, le cifriamo».
La quarta forma — l'hash veloce senza sale — è quella che dà la falsa sensazione più forte, perché sembra un hash. Ma MD5 e SHA-1 sono progettati per essere rapidi: l'hash di «estate2024» è già in tabelle precalcolate pubbliche, e cercarlo costa quanto una ricerca su Google. Il passaggio all'hashing serio è trattato in dettaglio in hash delle password con bcrypt e Argon2id.
Perché il danno non si ferma al vostro sito#
La ragione per cui questo errore è più grave di quanto sembri sta fuori dalla vostra applicazione. Una parte consistente delle persone riusa le stesse credenziali su servizi diversi. Un elenco di coppie email/password rubato da un sito qualunque diventa una chiave da provare ovunque — è il credential stuffing, ed è oggi il vettore più produttivo per prendere il controllo di account altrui.
In pratica: se il vostro sito perde le password in chiaro, il danno che avete causato non è ai vostri utenti sul vostro sito. È ai loro conti bancari, alle loro caselle email, ai loro account aziendali. È un danno che non potete misurare né riparare.
Reset, non recupero#
La forma corretta non «recupera» niente: permette di impostare una password nuova, dimostrando il controllo di un canale già verificato — di norma l'indirizzo email. Il flusso ha cinque punti in cui è facile sbagliare, e li affrontiamo uno per uno.
Il token è una credenziale#
// Il token di reset è una credenziale a tutti gli effetti: per la durata// della sua vita vale quanto la password. Va trattato come tale.const crypto = require('crypto');function creaToken() { // 32 byte da CSPRNG. Math.random() NON è un CSPRNG: è prevedibile, // e da qualche estrazione si ricostruisce lo stato del generatore. const grezzo = crypto.randomBytes(32).toString('base64url'); // Nel database va l'HASH del token, non il token. // Motivo: se il database viene letto, i token trovati non devono essere // utilizzabili. Vale per i token quanto vale per le password. const impronta = crypto.createHash('sha256').update(grezzo).digest('hex'); return { grezzo, impronta };}Due dettagli, entrambi decisivi. Il primo: Math.random() non è un generatore crittografico. Produce numeri statisticamente ben distribuiti e completamente prevedibili: osservate qualche uscita e ricostruite lo stato interno, quindi tutte le successive. Serve crypto.randomBytes.
Il secondo, che manca in molte implementazioni: nel database va l'impronta del token, non il token. Il ragionamento è identico a quello delle password. Un token di reset valido, letto da un attaccante che ha accesso in lettura al database, è una presa di controllo immediata di quell'account. Se nel database c'è solo l'hash, non c'è niente da usare.
La richiesta non deve rivelare chi è iscritto#
// La richiesta di reset. Tre proprietà da rispettare, tutte e tre facili// da perdere per distrazione.app.post('/reset/richiesta', limitaRichieste, async (req, res) => { const email = String(req.body.email || '').trim().toLowerCase(); const utente = await utenti.perEmail(email); if (utente) { const { grezzo, impronta } = creaToken(); await reset.crea({ utenteId: utente.id, impronta, scadeIl: Date.now() + 30 * 60_000, // 30 minuti, non 7 giorni usatoIl: null }); await invia(email, `https://esempio.it/reset/${grezzo}`); } // 1. La risposta è IDENTICA che l'indirizzo esista o no. Altrimenti // l'endpoint diventa uno strumento per enumerare gli utenti iscritti. // 2. Anche il TEMPO deve essere simile: se il ramo "esiste" manda una // email e l'altro no, la differenza è misurabile. L'invio va accodato, // non atteso. res.json({ esito: 'Se l\'indirizzo è registrato, riceverai un messaggio.' });});Se la risposta è «email inviata» per gli indirizzi registrati e «indirizzo non trovato» per gli altri, avete costruito un servizio di verifica iscrizioni gratuito. Non è una questione teorica: su un sito medico, su un forum tematico o su un servizio finanziario, sapere chi è iscritto è già l'informazione sensibile.
Il consumo deve essere atomico#
// Il consumo del token. Il punto delicato è l'atomicità: fra "l'ho trovato// valido" e "l'ho marcato usato" non deve poterci stare una seconda richiesta.async function consuma(tokenGrezzo, nuovaPassword) { const impronta = crypto.createHash('sha256').update(tokenGrezzo).digest('hex'); // UPDATE condizionale: è il database a garantire che una sola richiesta // vinca. Con SELECT-poi-UPDATE due richieste simultanee passano entrambe. const riga = await db.get(` UPDATE reset SET usatoIl = ? WHERE impronta = ? AND usatoIl IS NULL AND scadeIl > ? RETURNING utenteId`, [Date.now(), impronta, Date.now()]); if (!riga) return null; // scaduto, già usato o inesistente await utenti.aggiornaPassword(riga.utenteId, await hash(nuovaPassword)); // Chi aveva già una sessione aperta deve uscire: il cambio password serve // proprio a estromettere chi non dovrebbe essere dentro. await sessioni.invalidaTutte(riga.utenteId); await reset.invalidaAltri(riga.utenteId); // Notifica all'indirizzo, SEMPRE. È l'unico modo in cui l'utente scopre // un reset che non ha chiesto lui. await avvisaCambioPassword(riga.utenteId); return riga.utenteId;}Il punto è la differenza fra SELECT-poi-UPDATE e un UPDATE condizionale. Nel primo caso, due richieste che arrivano nello stesso millisecondo leggono entrambe usatoIl IS NULL, procedono entrambe e il token «monouso» viene usato due volte. È una race condition classica, e si chiude lasciando al database la decisione: WHERE usatoIl IS NULL dentro l'UPDATE, una sola riga aggiornata, una sola richiesta che vince.
Al cambio password, tutte le sessioni cadono#
È il passaggio più spesso dimenticato, e vanifica il resto. Lo scenario in cui il reset serve davvero è: qualcuno è già entrato nell'account. L'utente se ne accorge e cambia la password. Se le sessioni attive restano valide, l'intruso è ancora dentro e la password nuova non gli ha tolto niente.
Insieme alle sessioni vanno invalidati gli altri token di reset pendenti, i token «ricordami» e — se ci sono — i token di accesso alle API emessi per quell'utente.
Notificare, sempre#
Un'email all'indirizzo registrato dopo ogni cambio password, con data, ora e provenienza approssimativa. Non è cortesia: è l'unico canale attraverso cui un utente può accorgersi di un reset che non ha chiesto, mentre è ancora in tempo per intervenire.
Rifiutare le password già compromesse#
A monte di tutto, la difesa più efficace contro il credential stuffing è impedire in ingresso le password che sono già in circolazione. Si può fare senza inviare la password a nessuno:
// Confrontare la password scelta con quelle già compromesse, senza mandarla// a nessuno: si spediscono i primi 5 caratteri dell'hash SHA-1 e si riceve// l'elenco dei suffissi corrispondenti (k-anonymity). Il servizio non può// sapere quale delle ~800 risposte fosse la vostra.async function giaCompromessa(password) { const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); const prefisso = sha1.slice(0, 5); const suffisso = sha1.slice(5); const r = await fetch(`https://api.pwnedpasswords.com/range/${prefisso}`, { headers: { 'Add-Padding': 'true' } // uniforma la dimensione della risposta }); return (await r.text()) .split('\n') .some(riga => riga.split(':')[0] === suffisso);}Il meccanismo è la k-anonymity: si trasmettono solo i primi cinque caratteri dell'impronta SHA-1 e si riceve l'elenco di tutti i suffissi che cominciano così — tipicamente qualche centinaio. Il confronto avviene sul vostro server. Il servizio remoto non può sapere quale delle risposte fosse quella cercata, né se ce ne fosse una.
Il modo silenzioso in cui succede lo stesso#
Si può fare tutto correttamente — Argon2id, sale, pepper — e avere comunque le password in chiaro, in un posto a cui nessuno ha pensato.
// La password può finire in chiaro anche senza che nessuno l'abbia salvata:// basta un log troppo generoso. Questo middleware sembra innocuo.app.use((req, res, next) => { logger.info({ percorso: req.path, corpo: req.body }); // ← password nei log next();});// I log finiscono in un servizio di aggregazione, in un backup, in uno// screenshot di un ticket. Vanno filtrati alla fonte, con un elenco di// permessi e non di divieti: quello che non è previsto non passa.const CAMPI_AMMESSI = new Set(['email', 'nome', 'pagina', 'riferimento']);function ripulisci(oggetto) { const fuori = {}; for (const [k, v] of Object.entries(oggetto || {})) { fuori[k] = CAMPI_AMMESSI.has(k) ? v : '[omesso]'; } return fuori;}Un middleware che registra il corpo delle richieste cattura anche POST /login. Da lì le password finiscono nel servizio di aggregazione dei log, nei backup, nelle dashboard condivise, negli screenshot allegati ai ticket di supporto — cioè in una decina di posti con controlli d'accesso più deboli del database.
La regola operativa è filtrare alla fonte con un elenco di permessi e non di divieti: censurare i campi che si chiamano «password» lascia passare pwd, passphrase, vecchiaPassword e il campo che aggiungerà un collega fra sei mesi.
| Dove guardare | Che cosa cercare |
|---|---|
| Log applicativi | middleware che registrano req.body senza filtro |
| Log di accesso del proxy | password passate in querystring invece che nel corpo |
| Tracciamento degli errori | lo stack trace di un errore di login include spesso le variabili locali |
| Cronologia della shell del database | ~/.psql_history dopo un UPDATE manuale |
| Email di sistema | messaggi di benvenuto che «ricordano» le credenziali scelte |
| Sistema di ticket | password incollate da un utente in una richiesta di assistenza |
Se il problema è già in produzione#
Trovarsi con un database così, tipicamente ereditato, non è raro. Il rientro ha un ordine preciso e la prima mossa non è avvisare: è ridurre il danno.
-- Cosa fare se scoprite di avere password in chiaro in produzione.-- Passo 1: hasharle TUTTE, subito, senza aspettare il login degli utenti.-- Da questo momento il danno di un dump è limitato.ALTER TABLE utenti ADD COLUMN password_hash TEXT;-- (script applicativo: per ogni riga, password_hash = argon2(password))-- Passo 2: eliminare la colonna in chiaro. DROP COLUMN non basta:-- i dati restano nelle pagine liberate e nei backup precedenti.ALTER TABLE utenti DROP COLUMN password;VACUUM; -- SQLite/Postgres: riscrive il file-- Passo 3: invalidare tutte le sessioni e forzare il reset al login.UPDATE utenti SET deve_reimpostare = 1;DELETE FROM sessioni;Sull'ultimo punto, quello che non è tecnico: se le password erano leggibili e c'è il minimo indizio che qualcuno le abbia lette, gli utenti vanno avvisati — perché il rischio vero non è sul vostro sito, è sugli altri servizi dove hanno riusato quella stessa password. In ambito europeo, per una violazione di dati personali, il GDPR fissa anche i termini: notifica al Garante entro 72 ore, comunicazione agli interessati quando il rischio per i loro diritti è elevato.