Vai al contenuto
CodeAuditAgent
Tutti gli articoli

Errori comuni con JWT e sessioni, e come evitarli

Gli errori su JWT e sessioni che portano all'account takeover: confusione degli algoritmi, segreti deboli, claim non verificati, storage dei token e logout.

· 7 min di lettura · Lina Source LLC

I JSON Web Token sono un formato ragionevole con un lungo elenco di spigoli vivi. Il token in sé è raramente il problema. I bug vivono nel modo in cui viene verificato, in dove viene conservato, in quanto a lungo resta valido e in ciò che accade quando un utente si disconnette. Ognuno di questi errori ha lo stesso esito: qualcuno possiede un token che non dovrebbe avere, e il tuo server lo accetta.

Le due debolezze che ricorrono più spesso sono CWE-347, verifica impropria di una firma crittografica, e CWE-613, scadenza della sessione insufficiente. Gli esempi qui sotto usano jose, una libreria JavaScript per JWT molto diffusa che funziona in Node.js, nei runtime edge e nei browser.

Decodificare non è verificare

La forma più diretta di CWE-347 è leggere i claim di un token senza verificarne la firma. Ogni libreria JWT ha una funzione di decodifica per il debug, e compare nei middleware di autenticazione più spesso di quanto dovrebbe. Un token decodificato è solo base64 che chiunque può scrivere. Nelle codebase JavaScript lo schema si nasconde spesso in un piccolo helper che divide il token sui punti ed esegue JSON.parse sulla parte centrale. Funziona in ogni test, perché i token di test sono validi, e accetta ogni token falsificato in produzione.

import { decodeJwt, jwtVerify } from 'jose';

// Vulnerabile: chiunque può creare un token con sub impostato a un ID utente qualsiasi
const claims = decodeJwt(token);
const userId = claims.sub;

// Corretto: firma, algoritmo e claim vengono verificati prima
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;

alg none e confusione degli algoritmi

L'header del JWT indica quale algoritmo ha firmato il token, e l'header è controllato dall'attaccante. Dal fidarsi di esso derivano due attacchi classici. Il primo è alg impostato a none: un token non firmato che alcune librerie più vecchie accettavano come valido. Il secondo è la confusione degli algoritmi: a un server che si aspetta RS256 ma lascia scegliere l'algoritmo all'header si può consegnare un token HS256 firmato usando come segreto HMAC la chiave pubblica del server. La chiave pubblica è pubblica, quindi l'attaccante può firmare qualsiasi cosa.

Le librerie moderne si difendono da entrambi, e jose rifiuta i token non firmati in jwtVerify e verifica che il tipo di chiave corrisponda all'algoritmo. Non affidarti però ai soli valori predefiniti. Fissa esplicitamente l'elenco degli algoritmi a ogni chiamata di verifica, così un futuro refactoring o un cambio di libreria non potrà allargarlo in silenzio. Se accetti più di un algoritmo, per esempio durante una migrazione di chiavi, elencali entrambi esplicitamente e usa una chiave separata per ciascuno. Quando ruoti le chiavi, metti un kid nell'header e seleziona la chiave per kid dal tuo elenco, mai da un URL indicato nell'header del token.

Segreti di firma deboli

Un token HS256 è firmato con un segreto condiviso. Se il segreto è corto o indovinabile, come «secret», il nome dell'applicazione o un valore copiato da un tutorial, un attaccante in possesso di un solo token valido può forzarlo offline con strumenti comuni e poi firmare token per qualsiasi utente. Sui tentativi offline non esiste alcun rate limit.

  • Usa almeno 32 byte casuali per HS256, generati con qualcosa come openssl rand -base64 32.
  • Carica il segreto dall'ambiente o da un secret manager, e fai fallire l'avvio se manca o è troppo corto.
  • Non committarlo mai, e ruotalo se è mai stato committato.
  • Se più servizi devono verificare i token ma solo uno deve emetterli, usa un algoritmo asimmetrico come RS256 o EdDSA, così chi verifica possiede solo la chiave pubblica.

Valida exp, aud e iss

Una firma valida dimostra solo chi ha emesso il token. Sono i claim a stabilire se è destinato a te e se è ancora attuale. Un token senza scadenza è valido per sempre. Un token emesso per la tua API mobile non dovrebbe essere accettato dal tuo servizio di amministrazione solo perché entrambi si fidano dello stesso identity provider. Aud e iss servono proprio a questo.

import { SignJWT, jwtVerify } from 'jose';

const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
  throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';

export function signAccessToken(userId: string, tokenVersion: number) {
  return new SignJWT({ tv: tokenVersion })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('10m')
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

jose verifica exp ogni volta che è presente, ma un token privo di exp passerebbe comunque. L'opzione requiredClaims chiude questa lacuna. Per i token provenienti da un identity provider esterno, verificali con il suo key set pubblicato tramite createRemoteJWKSet e continua comunque a fissare algoritmi, issuer e audience.

localStorage o cookie httpOnly

Conservare un token in localStorage lo rende leggibile da qualsiasi script presente nella pagina. Basta un bug XSS, o uno script di terze parti compromesso, e il token può essere inviato a un attaccante e usato da qualsiasi luogo finché non scade.

Un cookie httpOnly non può essere letto da JavaScript. L'XSS resta grave, perché lo script iniettato può eseguire richieste come l'utente finché la pagina è aperta, ma non può rubare una credenziale di lunga durata e riutilizzarla in seguito. Per le applicazioni browser che parlano con il proprio backend, i cookie sono l'impostazione predefinita migliore. Per una single-page app che chiama un'API separata, tenere il token di accesso solo in memoria e il refresh token in un cookie httpOnly è una via di mezzo ragionevole.

// Express: cookie di sessione con attributi sicuri
res.cookie('__Host-session', accessToken, {
  httpOnly: true, // non leggibile da JavaScript
  secure: true, // solo HTTPS; richiesto dal prefisso __Host-
  sameSite: 'lax', // non inviato sulle POST cross-site
  path: '/', // richiesto dal prefisso __Host-
  maxAge: 10 * 60 * 1000, // millisecondi, pari alla durata del token
});

Il prefisso __Host- dice al browser di rifiutare il cookie se non è Secure, se il path non è impostato a / e se ha un attributo Domain, il che impedisce a un sottodominio compromesso di sovrascriverlo.

SameSite e ciò che non copre

SameSite=Lax blocca il cookie sulle richieste POST cross-site e sui caricamenti di sottorisorse, il che elimina gran parte del CSRF classico. Il cookie viene però ancora inviato sulle navigazioni GET di primo livello, quindi qualsiasi endpoint GET che modifica lo stato resta esposto. Strict blocca anche quelle, ma disconnette gli utenti quando seguono un link verso la tua applicazione da un'email o da un altro sito. None disattiva la protezione e richiede Secure.

Lax, più la regola che le richieste GET non modificano mai lo stato, è una base solida. Per le mutazioni sensibili, aggiungi un controllo dell'header Origin o un token CSRF. Ricorda che SameSite considera same-site tutti i sottodomini del tuo dominio registrabile, quindi un sottodominio vulnerabile può comunque falsificare richieste.

Rotazione, revoca e logout reale

Un JWT stateless non può essere revocato prima della sua scadenza; è il compromesso che si accetta per non interrogare un database a ogni richiesta. Il CWE-613 descrive che cosa succede quando quel compromesso viene ignorato: logout, cambi di password e sospensioni di account che in realtà non interrompono l'accesso. Anche cancellare un account, cambiare un ruolo e rimuovere qualcuno da un team sono eventi di revoca, e ciascuno dovrebbe avere effetto alla richiesta successiva, non alla successiva scadenza del token.

  • Mantieni i token di accesso di breve durata, nell'ordine dei minuti, non dei giorni.
  • Conserva i refresh token lato server, sotto forma di hash, e ruotali a ogni uso.
  • Se un refresh token già ruotato viene usato di nuovo, trattalo come un furto e revoca l'intera famiglia di token.
  • Aggiungi una versione del token alla riga dell'utente e al token stesso; incrementala al logout da tutti i dispositivi, al cambio di password o alla sospensione.
  • Rigenera l'identificatore di sessione al login per prevenire la session fixation.
export async function requireUser(token: string) {
  const payload = await verifyAccessToken(token);

  const user = await db.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, tokenVersion: true, disabled: true },
  });

  // Una versione incrementata o un account disattivato chiudono subito l'accesso
  if (!user || user.disabled || user.tokenVersion !== payload.tv) {
    throw new Error('Session revoked');
  }
  return user;
}

Quella lettura riporta una query al database per ogni richiesta: è il costo onesto della revoca. Molte applicazioni sono più semplici e più sicure con un ID di sessione opaco in un cookie e una tabella delle sessioni. In quel caso il logout consiste nel cancellare una riga. Usa i JWT dove le loro proprietà servono davvero, come per i token di breve durata tra servizi, non perché sono l'impostazione predefinita di un tutorial.

Qualunque scelta tu faccia, il logout deve avvenire sul server. Cancellare il cookie o eliminare il token nel browser rimuove solo la copia dell'utente; una copia rubata continua a funzionare finché il server non la rifiuta. Al logout, elimina la sessione lato server o il refresh token, cancella il cookie usando lo stesso nome, lo stesso path e gli stessi attributi con cui è stato impostato, e incrementa la versione del token se l'utente ha scelto di disconnettersi ovunque. Anche una reimpostazione della password dovrebbe chiudere tutte le altre sessioni.

Una breve checklist di revisione

  • Cerca le chiamate di decodifica nei percorsi di autenticazione.
  • Verifica che ogni chiamata di verifica fissi algoritmi, issuer e audience e richieda exp.
  • Controlla come il segreto di firma viene generato, caricato e validato all'avvio.
  • Individua dove i token vengono conservati nel browser.
  • Verifica che logout, cambio di password e sospensione dell'account chiudano le sessioni esistenti.

Questi controlli consistono soprattutto nel leggere i percorsi di codice da un capo all'altro, che è anche il modo in cui CodeAuditAgent affronta un audit: i problemi arrivano con la riga citata, il CWE, uno scenario di exploit e una patch, così un controllo di audience mancante può essere confermato e corretto in pochi minuti.