Salta al contenuto principale

04 - Autenticazione Sicura: Session e Cookie

Sessioni e cookie su un'app di prova: fissazione dell'id, logout, attributi e scadenza, password con scrypt e risposte che non rivelano chi è iscritto.

Cosa ne pensi di questo articolo?

04 - Autenticazione Sicura: Session e Cookie
Sicurezza WebFoto:Bibek ghoshPexels

In Breve

Sessioni e cookie su un'app di prova: fissazione dell'id, logout, attributi e scadenza, password con scrypt e risposte che non rivelano chi è iscritto.

Dopo il login, il server ha bisogno di ricordare chi sei a ogni richiesta, e l'unica cosa che tiene insieme le richieste è un identificatore di sessione dentro un cookie. Chi lo ruba, lo indovina o te lo fa adottare prima del login è, per il server, te. Qui costruisci una sessione piccola e la attacchi: fissazione dell'identificativo, logout che non chiude niente, cookie senza gli attributi giusti, sessioni che non scadono, password salvate male e messaggi di errore che rivelano chi è iscritto. Ognuna ha la sua prova e la sua correzione.

Che cosa promette una sessione

Nella Top 10 2025 questi difetti stanno in A07, Authentication Failures, la cui pagina cita fra le debolezze principali l'uso di password codificate nel programma (CWE-798), l'autenticazione impropria (CWE-287) e la fissazione della sessione (CWE-384). Un identificativo di sessione, dice la guida OWASP, deve avere almeno 64 bit di entropia e nascere da un generatore di numeri casuali crittograficamente sicuro; non deve contenere dati dell'utente, perché il suo unico lavoro è essere impossibile da prevedere. Negli esempi che seguono l'identificativo è randomBytes(32) di node:crypto, cioè 256 bit.

Tutto gira su applicazioni di prova, in-process: inject() di Fastify 5.12.8 (la versione sul registro npm il 13 novembre 2026) consegna la richiesta all'applicazione senza aprire una porta, e le sessioni stanno in una Map in memoria. Il browser non c'è: i cookie viaggiano perché lo script li copia nell'intestazione, come farebbe un browser. Quando una prova dipende da un comportamento del browser, il testo lo dice.

Fissazione della sessione e logout

Nella fissazione l'attaccante visita il sito, riceve un identificativo anonimo, e riesce a farlo adottare alla vittima (con un link, o scrivendo un cookie da un sottodominio). Se il server, al login, lascia la stessa sessione e si limita a segnarla come autenticata, l'attaccante possiede già l'identificativo di una sessione ora autenticata. La guida OWASP prescrive di rinnovare l'identificativo dopo ogni cambio di privilegio, e il login è il caso più importante. Lo script esegue il copione in quattro passi per due versioni dell'applicazione: la riga decisiva è nel ramo rigeneraAlLogin, che cancella la vecchia sessione e ne crea una nuova.

import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';

function costruisci({ rigeneraAlLogin }) {
  const sessioni = new Map(); // sid -> { utente | null }
  const nuovo = () => randomBytes(32).toString('hex');
  const leggi = (req) => /(?:^|; )sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
  const app = Fastify();

  app.get('/', async (req, reply) => {          // chi arriva senza sessione ne riceve una anonima
    let sid = leggi(req);
    if (!sid || !sessioni.has(sid)) {
      sid = nuovo();
      sessioni.set(sid, { utente: null });
      reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
    }
    return { sid };
  });
  app.post('/login', async (req, reply) => {   // credenziali omesse: qui interessa solo la sessione
    let sid = leggi(req);
    if (!sid || !sessioni.has(sid)) return reply.code(400).send({ errore: 'nessuna sessione' });
    if (rigeneraAlLogin) {
      sessioni.delete(sid);                     // la vecchia sessione muore
      sid = nuovo();
      sessioni.set(sid, { utente: 'alice' });
      reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
    } else {
      sessioni.get(sid).utente = 'alice';       // stessa sessione, ora autenticata
    }
    return { ok: true };
  });
  app.get('/conto', async (req, reply) => {
    const s = sessioni.get(leggi(req));
    return s?.utente ? { utente: s.utente } : reply.code(401).send({ errore: 'non autenticato' });
  });
  app.post('/logout', async (req) => {
    sessioni.delete(leggi(req));                // distrugge la sessione lato server
    return { ok: true };
  });
  return app;
}

const cookieDi = (r) => r.headers['set-cookie']?.split(';')[0];
for (const rigeneraAlLogin of [false, true]) {
  const app = costruisci({ rigeneraAlLogin });
  // 1. L'attaccante visita il sito e ottiene un id anonimo; riesce a farlo adottare alla vittima.
  const sidAttaccante = cookieDi(await app.inject({ url: '/' }));
  // 2. La vittima, con quell'id, fa il login.
  const login = await app.inject({ method: 'POST', url: '/login', headers: { cookie: sidAttaccante } });
  const cookieVittima = cookieDi(login) ?? sidAttaccante;
  // 3. L'attaccante usa l'id che conosceva.
  const intrusione = await app.inject({ url: '/conto', headers: { cookie: sidAttaccante } });
  console.log(`rigenera al login = ${rigeneraAlLogin}: l'id noto all'attaccante dà ${intrusione.statusCode} ${intrusione.body}`);
  // 4. Logout: il cookie della vittima non deve più valere.
  await app.inject({ method: 'POST', url: '/logout', headers: { cookie: cookieVittima } });
  const dopo = await app.inject({ url: '/conto', headers: { cookie: cookieVittima } });
  console.log(`   dopo il logout il vecchio cookie dà ${dopo.statusCode}`);
}

// Output:
// rigenera al login = false: l'id noto all'attaccante dà 200 {"utente":"alice"}
//    dopo il logout il vecchio cookie dà 401
// rigenera al login = true: l'id noto all'attaccante dà 401 {"errore":"non autenticato"}
//    dopo il logout il vecchio cookie dà 401

Senza rigenerazione l'identificativo che l'attaccante conosceva dà 200 e il nome della vittima; con la rigenerazione dà 401, perché quella sessione non esiste più e la vittima ne ha una nuova. Il secondo esperimento riguarda il logout: lo script chiama sessioni.delete(...) sul server, e il cookie vecchio dà 401 in entrambi i casi. Il rischio opposto è un logout che si limita a far cancellare il cookie al browser: chi ha copiato il valore prima può riusarlo finché la sessione esiste sul server, e per questo la guida chiede di invalidare la sessione da entrambi i lati, con il lato server come parte indispensabile.

Gli attributi del cookie e la scadenza

L'intestazione Set-Cookie porta il nome, il valore e una serie di attributi che il browser applica. La tabella dice che cosa fa ciascuno e che cosa succede se lo dimentichi, secondo MDN e la guida OWASP.

AttributoChe cosa faSe manca
HttpOnlyVieta a JavaScript l'accesso al cookie (document.cookie)Uno script iniettato può leggere l'identificativo
SecureIl cookie viaggia solo su https: (localhost escluso)Può essere inviato in chiaro e intercettato
SameSiteStrict, Lax o None: se allegarlo alle richieste cross-siteDipende dal browser, con un Lax più permissivo (vedi l'articolo 02)
DomainEstende il cookie al dominio e a tutti i sottodominiIl cookie va solo al server che l'ha emesso: è la scelta più sicura
Path=/ e prefisso __Host-Il prefisso fa rifiutare al browser il cookie se non è Secure, senza Domain e con Path=/Un sottodominio può impostare un cookie con lo stesso nome (falsificazione dal sottodominio)
Max-AgeSecondi di vita nel browserCookie di sessione del browser, che sparisce alla chiusura (ma il server deve scadere comunque la sessione)

Due precisazioni importanti. HttpOnly impedisce di leggere il cookie, non di usarlo: MDN avverte che un cookie HttpOnly viene comunque inviato con le richieste iniziate da JavaScript, quindi un XSS può fare richieste «a nome» dell'utente anche senza rubare l'identificativo; è per questo che le difese dell'articolo 02 restano necessarie. E Secure non protegge da tutto: MDN ricorda che il cookie può essere letto o modificato con accesso al disco del client. Lo script seguente costruisce il cookie con il prefisso __Host- e lo controlla, poi fa scadere la sessione con un orologio finto che decide lui quanti minuti sono passati.

import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';

const IDLE_MS = 15 * 60 * 1000;       // inattività massima
const ASSOLUTA_MS = 8 * 60 * 60 * 1000; // durata massima, qualunque sia l'attività

function costruisci(orologio) {
  const sessioni = new Map();
  const app = Fastify();
  app.post('/login', async (req, reply) => {
    const sid = randomBytes(32).toString('hex');
    sessioni.set(sid, { creata: orologio(), ultima: orologio(), utente: 'alice' });
    // __Host-: il browser accetta il cookie solo se Secure, senza Domain e con Path=/
    reply.header('set-cookie', `__Host-sid=${sid}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${ASSOLUTA_MS / 1000}`);
    return { ok: true };
  });
  app.get('/conto', async (req, reply) => {
    const sid = /(?:^|; )__Host-sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
    const s = sessioni.get(sid);
    const ora = orologio();
    if (s && (ora - s.ultima > IDLE_MS || ora - s.creata > ASSOLUTA_MS)) {
      sessioni.delete(sid);                      // scaduta: la si elimina davvero dal server
      return reply.code(401).send({ errore: 'sessione scaduta' });
    }
    if (!s) return reply.code(401).send({ errore: 'non autenticato' });
    s.ultima = ora;                              // l'attività sposta solo il limite di inattività
    return { utente: s.utente };
  });
  return app;
}

let adesso = 0; // orologio finto: i tempi sono quelli che decidiamo noi, non quelli della macchina
const app = costruisci(() => adesso);
const login = await app.inject({ method: 'POST', url: '/login' });
const header = login.headers['set-cookie'];
const cookie = header.split(';')[0];
const attributi = header.split('; ').slice(1);
console.log('Set-Cookie:', header.replace(/sid=[0-9a-f]+/, 'sid=<id>'));
console.log('regole __Host- rispettate:', header.startsWith('__Host-') && attributi.includes('Secure') && attributi.includes('Path=/') && !attributi.some((a) => /^Domain=/i.test(a)));
console.log('HttpOnly:', attributi.includes('HttpOnly'), '| SameSite:', attributi.find((a) => a.startsWith('SameSite')));

const chiedi = async (minuti) => {
  adesso = minuti * 60 * 1000;
  const r = await app.inject({ url: '/conto', headers: { cookie } });
  return `${r.statusCode} ${r.body}`;
};
console.log('minuto 10  :', await chiedi(10));
console.log('minuto 20  :', await chiedi(20), '(10 minuti dopo l\'ultima attività)');
const app2 = costruisci(() => adesso);
adesso = 0;
const c2 = (await app2.inject({ method: 'POST', url: '/login' })).headers['set-cookie'].split(';')[0];
let esito = '';
for (let m = 10; m <= 8 * 60 + 10; m += 10) { // un utente attivo ogni 10 minuti per 8 ore e 10 minuti
  adesso = m * 60 * 1000;
  const r = await app2.inject({ url: '/conto', headers: { cookie: c2 } });
  esito = `minuto ${m}: ${r.statusCode}`;
  if (r.statusCode !== 200) break;
}
console.log('utente sempre attivo, ultimo esito ->', esito);

// Output:
// Set-Cookie: __Host-sid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800
// regole __Host- rispettate: true
// HttpOnly: true | SameSite: SameSite=Lax
// minuto 10  : 200 {"utente":"alice"}
// minuto 20  : 200 {"utente":"alice"} (10 minuti dopo l'ultima attività)
// utente sempre attivo, ultimo esito -> minuto 490: 401

La prima parte dell'uscita verifica le tre regole del prefisso sull'intestazione che l'applicazione ha davvero emesso, un modo semplice per metterle in un test automatico. La seconda applica i due limiti che la guida OWASP chiede a ogni sessione: il limite di inattività (qui 15 minuti, ma la guida indica 2-5 minuti per le applicazioni ad alto valore e 15-30 per quelle a basso rischio) e il limite assoluto (qui 8 ore, nell'intervallo di 4-8 ore che la guida cita per un'applicazione usata da un impiegato per tutta la giornata). A 10 e a 20 minuti l'utente è ancora dentro perché tra una richiesta e l'altra passano 10 minuti; l'utente che chiede ogni 10 minuti viene comunque buttato fuori a 490 minuti, perché ha superato le 8 ore dalla creazione. Un dettaglio che si dimentica: la scadenza si applica sul server (la sessione viene cancellata), non si affida al Max-Age del browser, che si può ignorare.

Le password: come si salvano e che cosa si risponde

Il login vero richiede di salvare le password in modo che un furto del database non le consegni (e di leggerle con query parametrizzate, come nell'articolo 03). La guida OWASP raccomanda Argon2id; se non è disponibile, scrypt con costo minimo N=2^17, blocco r=8 e parallelismo p=1, che richiede 128 MiB di memoria per ogni calcolo. node:crypto offre scrypt senza dipendenze, ed è quello che usa lo script, con due trappole che compaiono eseguendolo: il limite di memoria predefinito di Node rifiuta N=2^17 con un errore (maxmem), e la stringa salvata deve portare con sé i parametri e il sale, per poterli alzare in futuro senza perdere la compatibilità.

import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';

// Parametri minimi indicati da OWASP per scrypt: N=2^17, r=8, p=1.
const N = 2 ** 17, r = 8, p = 1, LUNGHEZZA = 32;
const maxmem = 256 * 1024 * 1024; // il limite predefinito di Node (32 MiB) rifiuta N=2^17

function hashPassword(password) {
  const sale = randomBytes(16);
  const derivata = scryptSync(password, sale, LUNGHEZZA, { N, r, p, maxmem });
  // I parametri stanno nella stringa: potrai alzarli in futuro e verificare ancora i vecchi hash.
  return ['scrypt', N, r, p, sale.toString('base64'), derivata.toString('base64')].join('$');
}

function verifica(password, memorizzato) {
  const [schema, n, rr, pp, sale, atteso] = memorizzato.split('$');
  if (schema !== 'scrypt') return false;
  const derivata = scryptSync(password, Buffer.from(sale, 'base64'), LUNGHEZZA,
    { N: Number(n), r: Number(rr), p: Number(pp), maxmem });
  const a = Buffer.from(atteso, 'base64');
  return a.length === derivata.length && timingSafeEqual(a, derivata);
}

try {
  scryptSync('x', 'sale', 32, { N, r, p }); // senza maxmem
} catch (e) {
  console.log('senza maxmem:', e.code, '-', e.message);
}
const h1 = hashPassword('correct horse battery staple');
const h2 = hashPassword('correct horse battery staple');
console.log('formato:', h1.split('$').slice(0, 4).join('$') + '$<sale>$<hash>');
console.log('password giusta :', verifica('correct horse battery staple', h1));
console.log('password errata :', verifica('correct horse battery stapler', h1));
console.log('stessa password, hash diversi (sale casuale):', h1 !== h2);
console.log('lunghezza della stringa salvata:', h1.length, 'caratteri');

// Output:
// senza maxmem: ERR_CRYPTO_INVALID_SCRYPT_PARAMS - Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded
// formato: scrypt$131072$8$1$<sale>$<hash>
// password giusta : true
// password errata : false
// stessa password, hash diversi (sale casuale): true
// lunghezza della stringa salvata: 87 caratteri

L'errore ERR_CRYPTO_INVALID_SCRYPT_PARAMS arriva dalla riga con scryptSync senza maxmem: il limite predefinito è 32 MiB, e l'opzione maxmem lo alza a 256 MiB, il valore che serve per N=2^17. Il sale è casuale per ogni password, quindi due utenti con la stessa password hanno hash diversi; timingSafeEqual confronta i due hash senza che il tempo dipenda dal primo byte diverso. Il tempo di un calcolo: su 12 esecuzioni, in un computer di sviluppo con Node 24.11.0, ho misurato tra 317 e 369 ms: cambia da macchina a macchina, e quello è il costo che paga anche un attaccante per ogni tentativo. Per la scelta fra algoritmi, e il perché del costo, vedi l'articolo 07.

Per le regole sulle password stesse, il documento di riferimento è NIST SP 800-63B-4: pretende almeno 15 caratteri per le password usate come unico fattore (8 se sono parte di un'autenticazione a più fattori), raccomanda di consentire una lunghezza massima di almeno 64 caratteri, vieta le regole di composizione (maiuscole, simboli obbligatori) e vieta di imporre cambi periodici, salvo evidenza di compromissione. Resta l'ultimo difetto: il messaggio di errore. La guida OWASP sull'autenticazione chiede una risposta generica, che non cambi se l'utente non esiste, la password è sbagliata o l'account è bloccato. Lo script confronta le due versioni.

import Fastify from 'fastify';
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';

// Costo ridotto solo per la prova: in produzione usa i parametri dell'esempio precedente.
const PARAMS = { N: 2 ** 14, r: 8, p: 1 };
const hashDi = (pw, sale) => scryptSync(pw, sale, 32, PARAMS);
const SALE_FINTO = randomBytes(16);
const HASH_FINTO = hashDi('password-che-nessuno-usera', SALE_FINTO);

const utenti = new Map();
const registra = (email, pw) => {
  const sale = randomBytes(16);
  utenti.set(email, { sale, hash: hashDi(pw, sale) });
};
registra('alice@example.test', 'una frase lunga e unica');

function costruisci(uniforme) {
  const app = Fastify();
  let confronti = 0;
  app.post('/login', async (req, reply) => {
    const { email, password } = req.body;
    const u = utenti.get(email);
    if (!uniforme && !u) return reply.code(404).send({ errore: 'utente inesistente' });
    // Uniforme: anche per un utente inesistente si fa lo stesso lavoro e si dà la stessa risposta.
    const sale = u ? u.sale : SALE_FINTO;
    const atteso = u ? u.hash : HASH_FINTO;
    confronti += 1;
    const ok = timingSafeEqual(hashDi(password, sale), atteso) && Boolean(u);
    return ok ? { ok: true } : reply.code(401).send({ errore: 'credenziali non valide' });
  });
  return { app, confronti: () => confronti };
}

for (const uniforme of [false, true]) {
  const { app, confronti } = costruisci(uniforme);
  const post = (email, password) => app.inject({ method: 'POST', url: '/login', payload: { email, password } });
  const esistente = await post('alice@example.test', 'sbagliata');
  const inesistente = await post('nessuno@example.test', 'sbagliata');
  const valido = await post('alice@example.test', 'una frase lunga e unica');
  console.log(uniforme ? '--- risposta uniforme' : '--- risposte diverse');
  console.log('utente esistente, password errata:', esistente.statusCode, esistente.body);
  console.log('utente inesistente               :', inesistente.statusCode, inesistente.body);
  console.log('stessa risposta (non si enumerano gli utenti):', esistente.body === inesistente.body && esistente.statusCode === inesistente.statusCode);
  console.log('login valido:', valido.statusCode, '| calcoli di hash eseguiti:', confronti());
}

// Output:
// --- risposte diverse
// utente esistente, password errata: 401 {"errore":"credenziali non valide"}
// utente inesistente               : 404 {"errore":"utente inesistente"}
// stessa risposta (non si enumerano gli utenti): false
// login valido: 200 | calcoli di hash eseguiti: 2
// --- risposta uniforme
// utente esistente, password errata: 401 {"errore":"credenziali non valide"}
// utente inesistente               : 401 {"errore":"credenziali non valide"}
// stessa risposta (non si enumerano gli utenti): true
// login valido: 200 | calcoli di hash eseguiti: 3

Nella versione ingenua l'applicazione risponde 404 «utente inesistente» e 401 «credenziali non valide»: un attaccante scopre quali indirizzi sono iscritti. Nella versione uniforme i due casi danno la stessa risposta, e lo script fa il calcolo dell'hash anche per l'utente che non c'è, con un hash finto; lo si vede dal contatore, che passa da 2 a 3 calcoli per le stesse tre richieste. Questo serve a rendere uguale anche il lavoro svolto: con un hash lento, un utente inesistente risolto subito potrebbe rivelarsi dal tempo di risposta. Non ho misurato quei tempi, solo contato i calcoli. L'uniformità vale anche per la registrazione e il recupero della password, come ricorda la stessa guida.

Domande frequenti

Meglio una sessione sul server o un JWT?

Per un sito web con login, una sessione sul server con un cookie è la scelta più semplice da revocare: il logout cancella la sessione, e un identificativo rubato smette di valere. I JWT servono per altri scenari e hanno altri rischi; ne parla l'articolo 05.

HttpOnly basta a proteggere la sessione da un XSS?

Protegge dal furto del valore, non dall'uso: uno script iniettato può fare richieste che il browser accompagna con il cookie. Servono anche codifica dell'output e CSP, come nell'articolo 02.

Quanto deve durare una sessione?

Dipende dal rischio. La guida OWASP indica 2-5 minuti di inattività per applicazioni ad alto valore e 15-30 per quelle a basso rischio, con un limite assoluto che segue l'uso reale, per esempio 4-8 ore per un impiegato. I valori dell'esempio sono una scelta, non una regola.

Che cosa sai fare adesso

Sai riprodurre una fissazione di sessione e chiuderla rigenerando l'identificativo al login, scrivere un logout che distrugge la sessione sul server e controllare con un test gli attributi del cookie, compreso il prefisso __Host-. Sai far scadere una sessione per inattività e per durata assoluta, salvare una password con scrypt senza dimenticare maxmem e sale, e rispondere in modo uniforme per non rivelare chi è iscritto. Dove la sessione non basta, per le API e i token, prosegui con l'articolo 05.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti