02Sicurezza5 min

Perché gli accessi ospite li faccio con token di sessione e non con codici

Un codice di sei cifre sembra il modo semplice di far entrare qualcuno senza account. Lo è finché non devi revocarlo, e allora scopri che non sai chi stai buttando fuori.

Di Salva Castells

Immagine di copertina: Jessica, CC BY-SA 2.0.
In questo post

Qualcuno vuole mostrare un progetto a un cliente che non ha un account. La soluzione ovvia è un codice corto: sei cifre, scade in cinque minuti, l'ospite lo inserisce ed entra. L'ho implementato così in uno strumento di note e file condivisi, e ha funzionato dal primo giorno. Il problema non è stato che non funzionasse. È stato che tre mesi dopo ho dovuto rifarlo due volte.

Tre iterazioni, e la terza è quella che sarebbe dovuta essere la prima.

Cosa fa un codice corto e cosa no

La prima tabella era letteralmente questa:

CREATE TABLE guest_access_codes (
  id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  user_id text NOT NULL,
  code char(6) NOT NULL,
  permission permission NOT NULL,
  session_duration_minutes integer NOT NULL,
  code_expires_at timestamptz NOT NULL,
  is_used boolean DEFAULT false NOT NULL,
  used_at timestamptz,
  session_expires_at timestamptz
);

Due scadenze diverse, e questo sì è stato giusto fin dall'inizio: code_expires_at è quanto ci mette il codice a morire se nessuno lo usa (cinque minuti), e session_duration_minutes è quanto dura la visita una volta dentro. Sono cose diverse e mescolarle è un errore classico. Un codice che vive otto ore su WhatsApp è un buco; una sessione che si interrompe dopo cinque minuti è inusabile.

Con questo hai già i permessi (read o read_write), una tabella ponte per decidere a quali progetti dà accesso il codice, e la scadenza. Sembra completo. Non lo è: il codice non identifica nessuno. Identifica l'invito, non l'ospite.

Primo tentativo: passare l'id della riga

La prima correzione è stata smettere di mandare il codice di sei cifre a ogni richiesta e mandare l'id della riga. Il client salvava un codeId, il backend lo risolveva, e le richieste andavano con Authorization: Bearer <codeId>.

Questo risolve una cosa reale: il codice di sei cifre smette di viaggiare. Non sta più in un log di accesso né in un URL. Ma introduce qualcosa di peggio. Il codeId è un intero autoincrementale. È indovinabile. È enumerabile. Chiunque abbia un id valido può provare quello accanto e, se quella sessione è ancora viva, entra. Ho scambiato un segreto corto con un identificatore che non è affatto segreto.

E continua a non risolvere il problema originale, che era questo: se due persone usano lo stesso invito, condividono lo stesso codeId. Non puoi buttare fuori una senza buttare fuori l'altra. Non puoi sapere quale delle due ha cancellato il file.

Cosa risolve il token di sessione

La terza versione separa le due cose che erano state insieme per tre mesi. Il codice di sei cifre è un riscatto: si usa una volta, nell'endpoint di connessione, e mai più. Quello che ne esce è un token di sessione:

const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;

await db
  .update(guestAccessCodes)
  .set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
  .where(eq(guestAccessCodes.id, record.id));

La risposta non restituisce più codeId, restituisce sessionToken. Da lì in poi tutto il frontend se lo porta dietro: dove c'era guestCodeId è comparso guestSessionToken, e il !!guestCodeId che decideva se qualcuno era ospite è diventato !!guestSessionToken.

Cosa cambia davvero:

  • Revocare. Mettere il sessionToken a null in una riga butta fuori quella sessione e solo quella. Con il codice non avevi nessuno da puntare.
  • Auditare. Il token sta nella riga, quindi ogni azione si può attribuire a un riscatto preciso e a un'ora precisa.
  • Non indovinabile. Non è più un intero progressivo. Quattro byte casuali in esadecimale non si enumerano.
  • Un solo posto dove scade. Il sessionExpiresAt si scrive nello stesso update che emette il token. Non ci sono due orologi che possano desincronizzarsi.

Quello che ha trovato l'audit, e che io no

Mesi dopo ho fatto un audit al codice. È uscito di tutto — una rotta di esportazione in PDF senza nessun guard di autenticazione è la cosa peggiore della lista — ma quella che mi ha fatto pensare di più me la sono annotata io stesso nel TODO: anche se il codice di sei viene consumato, quello che restituisce è un token di sei più quattro, ed è quello che autentica.

Lì sta il difetto. Il codice corto è protetto dalla sua vita di cinque minuti e dal fatto che si può usare una volta sola. Il token di sessione non ha nessuna di queste due cose: dura ore e si usa a ogni richiesta. Passare da codice a token toglie un vettore e ne apre un altro, e te ne accorgi solo se lo guardi dopo a mente fredda. Quattro byte bastano contro l'enumerazione casuale, non contro chi ha otto ore e un ciclo.

L'altra cosa che è uscita, ed è da manuale, è che l'endpoint di connessione non racchiudeva niente in una transazione. Tra il leggere che il codice non è usato e il marcarlo come usato c'è una finestra. Due richieste simultanee con lo stesso codice passavano entrambe.

Bannare per IP è più fragile di quanto sembri

Il contatore di tentativi falliti deve sapere da che IP arriva la richiesta, ed è lì che quasi tutti sbagliano allo stesso modo. In un altro progetto avevo un ban permanente per IP e prendevo il primo valore di x-forwarded-for. Quell'header è una lista client, proxy1, proxy2: ogni proxy aggiunge a destra, ma nessuno ripulisce quello che c'era già scritto. Prendere il primo significa prendere una cosa che il client può inventarsi.

L'ho cambiato per contare dalla fine in base a quanti proxy fidati ci sono davanti:

const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];

Con un proxy davanti, l'ultimo valore è quello che scrive il proxy e il client non può toccarlo. Se prendi il primo, chiunque aggira il suo ban mandando spazzatura, o peggio, ne banna un altro.

Il codice di sei cifre non era la parte difficile. La parte difficile era accettare che un invito e una sessione sono due oggetti distinti, e che mescolarli ti lascia senza poter revocare, senza poter auditare e con un identificatore progressivo che fa da credenziale.