Il contesto

PanelControl è un pannello interno per la gestione di ordini, turni, ferie, chat e amministrazione, usato ogni giorno da più operatori con ruoli diversi (operativo, admin). Il backend è Firebase Realtime Database + Firestore, senza framework, con l'autenticazione degli operatori gestita da un login proprio dell'app — email e password verificate lato client contro un nodo del database, con un lockout dopo tentativi falliti.

La richiesta di partenza era circoscritta: aggiungere notifiche Telegram per eventi sospetti — troppi tentativi di login falliti, un nuovo dispositivo mai visto per un operatore, un tentativo di accesso alla sezione Admin da chi non ne ha diritto, l'apertura di DevTools durante l'uso dell'app. Un lavoro di alerting, non di architettura.

Il primo bug: il pulsante "Accedi" che sblocca sempre

Prima di scrivere l'alerting, un controllo del codice di login esistente ha fatto emergere un problema concreto nella funzione che verifica la password dell'area Admin:

checkAdminPwd() — prima del fix
// ❌ Il valore booleano "|| true" rende l'intera condizione sempre vera
function checkAdminPwd() {
  if (el.value || true) {
    unlockAdmin(); // eseguito sempre, password digitata o no
  }
}

Un residuo di debug rimasto in produzione: chiunque arrivasse sull'overlay password dell'Admin entrava premendo "Accedi", a prescindere da cosa (o niente) fosse scritto nel campo. Corretto insieme al resto richiamando davvero il controllo sui permessi dell'operatore, con difesa aggiuntiva anche se la funzione venisse invocata a mano dalla console.

La scoperta grave: una credenziale Firebase condivisa e hardcoded

Guardando le Regole del Realtime Database in vista dell'alerting, è emerso qualcosa di molto più serio del bug sull'Admin. Le Regole restringevano lettura e scrittura a un singolo auth.uid fisso — ragionevole in astratto, finché non si guarda chi ottiene quell'uid. Nel bootstrap dell'app, prima ancora che comparisse la schermata di login, veniva eseguito questo codice, incondizionatamente, per chiunque aprisse il sito:

initFirebase() — prima del fix
// ❌ Email e password Firebase in chiaro, eseguite per OGNI visitatore
var _fbEmail = ['[account-condiviso]', '[dominio]'].join('@');
var _fbPass  = '[PASSWORD_REDATTA]';
firebase.auth().signInWithEmailAndPassword(_fbEmail, _fbPass);

La conseguenza pratica: il vero sistema di login dell'app — email, password personale, verifica dei permessi — non proteggeva i dati Firebase in alcun modo. Era un filtro sull'interfaccia. Chiunque visitasse il sito, anche senza mai compilare un form, otteneva già una sessione con accesso completo in lettura/scrittura all'intero database: ordini, dati del personale, chat interne, tutto. Bastava aprire il sito, non serviva nemmeno leggere il sorgente per ottenere le credenziali — l'app se le autenticava da sola.

Il problema non era una svista isolata da correggere con una patch: le credenziali servite in un bundle JavaScript pubblico non possono mai essere davvero segrete, e con un'unica identità Firebase condivisa da tutti gli operatori le Regole non hanno modo di distinguere un operatore autorizzato da chiunque altro abbia semplicemente copiato quelle due righe in un client HTTP qualsiasi.

🔴

Le credenziali in un sorgente client non sono "difficili da trovare": sono pubbliche per definizione. Il vero perimetro di sicurezza di un'app Firebase sono le Regole del database, non l'interfaccia di login mostrata al browser.

La soluzione: Netlify Function di login + Custom Token con claim per ruolo

L'architettura corretta sposta la verifica dell'identità lato server, dove le credenziali sensibili (l'account di servizio Firebase) non sono mai esposte al client. Il flusso diventa:

  1. L'operatore inserisce email e password come sempre — ma questa volta vengono inviate a una Netlify Function
  2. La function verifica le credenziali contro il nodo operatori (hash PBKDF2 via Web Crypto API, con supporto a un formato legacy da migrare) usando il service account, mai esposto al client
  3. Se valide, calcola il ruolo (admin: true/false) e firma un Custom Token con dentro l'identità reale dell'operatore come claim
  4. Il client riceve il token e chiama signInWithCustomToken() invece del vecchio login hardcoded
  5. Le Regole del database vengono riscritte per leggere auth.token.operatore e auth.token.admin invece di un singolo uid fisso
operator-login.mjs — verifica e firma del token (semplificato)
// Netlify Function — gira solo lato server, mai nel bundle client
export default async function handler(req) {
  const { email, password } = await req.json();

  // 1. Rate-limit lato server: 5 tentativi, poi lockout 60s per email
  const attempts = await getAttempts(email);
  if (attempts.locked) return json({ ok: false, error: 'locked' }, 423);

  // 2. Verifica contro il nodo operatori (service account, mai nel client)
  const op = await findOperatorByEmail(email);
  const valid = op && await verifyPassword(password, op.passwordHash);
  if (!valid) { await registerFailedAttempt(email); return json({ ok: false }, 401); }

  // 3. Claim di ruolo calcolati server-side, non falsificabili dal client
  const claims = { operatore: op.chiave, admin: isAdmin(op) };
  const token = await mintCustomToken(`op_${op.chiave}`, claims);

  return json({ ok: true, token, operatore: op.chiave, admin: claims.admin });
}

Il punto chiave è che admin e operatore vengono decisi esclusivamente dal server, firmati dentro un JWT con la chiave privata del service account. Il client non può più "diventare admin" modificando una variabile in memoria: quel claim è verificato dalle Regole del database a ogni singola lettura o scrittura, non solo all'apertura della sessione.

Il rollout in 5 fasi, per non bloccare fuori nessuno

Sostituire l'identità Firebase di un'app usata ogni giorno da più persone non è un cambio da fare in un colpo solo. Il piano concordato prevedeva 5 fasi, ciascuna verificabile e reversibile prima di passare alla successiva:

  1. Fase 1 — function isolata. operator-login.mjs deployata ma non ancora richiamata da nessuna parte del client: zero rischio, testabile via chiamata diretta.
  2. Fase 2 — client con fallback. Il login prova prima la nuova function; se non risponde, ricade sul vecchio metodo — nessuno resta fuori durante la transizione.
  3. Fase 3 — verifica su account reali. Test con operatori admin e non-admin di reparti diversi, console del browser aperta a caccia di errori di permesso.
  4. Fase 4 — rimozione delle credenziali hardcoded. Tolto l'auto-login con la credenziale condivisa; l'app aspetta onAuthStateChanged() invece di autenticarsi da sola, con la persistenza della sessione sincronizzata con il flag "Ricordami".
  5. Fase 5 — Regole deny-by-default. Solo dopo che il Service Worker ha propagato il nuovo client a tutto il team, le Regole passano da un uid condiviso a permessi granulari per claim.

Il primo scoglio: le Regole vanno toccate a metà migrazione, non alla fine

Il piano originale prevedeva di lasciare le Regole invariate fino alla Fase 5. Alla Fase 2, non appena il client ha iniziato a autenticarsi con il Custom Token su un browser di test, sono comparsi errori permission_denied sui listener realtime della chat, ancora attivi da prima del login. Il motivo: cambiare identità Firebase a runtime invalida immediatamente i permessi di ciò che è già aperto sotto la vecchia identità. Le vecchie Regole autorizzavano solo l'uid condiviso — il nuovo Custom Token, per quanto valido, non c'entrava nulla per quelle Regole.

La correzione è stata un OR additivo, pubblicato subito, che non toglie nulla a chi è ancora sulla vecchia sessione:

Regole RTDB — transizione additiva
{
  ".read":  "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)",
  ".write": "auth != null && (auth.uid === '[UID_CONDIVISO]' || auth.token.operatore != null)"
}

Le Regole Firebase non gestiscono "sessioni": ogni lettura e scrittura viene rivalutata in tempo reale contro le Regole correnti. Allargare la condizione con un OR non disconnette nessuno e non invalida nulla — è sicuro da pubblicare anche in pieno orario di lavoro, a differenza della stretta finale.

Fase 4 — persistenza Firebase Auth sincronizzata con "Ricordami"

Togliere l'auto-login senza altro avrebbe costretto tutti a rifare login a ogni ricarica di pagina — inaccettabile per un'app usata tutto il giorno. Firebase Auth ha già una sua persistenza nativa indipendente dal login applicativo: una volta autenticati con signInWithCustomToken, la sessione sopravvive ai ricaricamenti senza bisogno di richiamare il login. Il punto delicato era sincronizzarla con il flag "Ricordami" dell'interfaccia, altrimenti chi non lo spuntava sarebbe comunque rimasto loggato a tempo indeterminato:

doLogin() — Fase 4
// La persistenza va impostata PRIMA del sign-in, non dopo
await firebase.auth().setPersistence(
  ricordami ? firebase.auth.Auth.Persistence.LOCAL
            : firebase.auth.Auth.Persistence.SESSION
);
const res = await fetch('/.netlify/functions/operator-login', { /* ... */ });
const { token } = await res.json();
await firebase.auth().signInWithCustomToken(token);
// nessun fallback: se la function non risponde, il login fallisce e basta

Al bootstrap, l'app non fa più login automatico ma ascolta onAuthStateChanged(): se esiste già una sessione valida (persistenza nativa), l'interfaccia carica direttamente; altrimenti mostra il form di login. E il logout ora chiama davvero signOut() — prima usciva solo dall'interfaccia, lasciando la sessione Firebase attiva in background, un dettaglio irrilevante con la vecchia identità condivisa, ma non più con identità reali per operatore.

Fase 5 — Regole deny-by-default, e i due bug che hanno bloccato l'app

Con il team ormai sulla versione nuova, le Regole passano dal modello additivo a uno realmente restrittivo: root false di default, con permessi granulari per ruolo su ogni nodo:

Regole RTDB — Fase 5, estratto
{
  ".read": false, ".write": false,
  "adminData": {
    ".read":  "auth.token.operatore != null",
    ".write": "auth.token.admin === true"
  },
  "operatoriDevices": {
    "$op": { ".read": "auth.token.operatore === $op || auth.token.admin === true",
             ".write": "auth.token.operatore === $op || auth.token.admin === true" }
  },
  "chat": { "dm": { "$myKey": { ".read": "auth.token.operatore === $myKey" } } }
  // ... resto dei nodi con "auth.token.operatore != null"
}

Al primo giro di test con un account non-admin, l'app si è aperta completamente vuota, senza errori vistosi in console. La causa: adminData era stato classificato come "solo admin" in lettura, ma era anche uno dei nodi che l'app legge per intero prima di mostrare qualsiasi schermata, a prescindere dal ruolo di chi è loggato. Bloccandone la lettura ai non-admin, l'intera app restava sospesa in attesa di una lettura che non si sarebbe mai completata — senza errore visibile perché quel listener specifico non aveva un callback di errore esplicito.

Un secondo nodo, con dati anagrafici degli operatori, ha rivelato un limite strutturale delle Regole Firebase: non filtrano parzialmente un nodo. Se il client lo legge tutto insieme (come faceva), il permesso è concesso o negato per l'intero contenuto — non è possibile dire "reparto ed email sì, hash della password no" all'interno dello stesso nodo senza prima separare i dati sensibili in un nodo a parte. Non essendo una modifica da fare a caldo durante un debug, quel nodo specifico è stato temporaneamente riportato aperto a tutti gli operatori — nessuna regressione, visto che con la vecchia identità condivisa l'esposizione era comunque totale — con la migrazione a due nodi separati pianificata come lavoro successivo.

⚠️

Prima di restringere un nodo a un ruolo specifico, va sempre verificato se è tra i dati che l'app legge al bootstrap per tutti gli utenti — non solo per chi dovrebbe vederlo secondo la logica applicativa.

Le notifiche di sicurezza, tornando alla richiesta originale

Chiuso il capitolo identità, restava la richiesta iniziale: notifiche Telegram per eventi sospetti. Implementate con un helper client che chiama una Netlify Function dedicata, con throttle per tipo+operatore su RTDB per evitare spam, e uno storico consultabile:

EventoTriggerNote
Brute forcelockout dopo 5 tentativi fallitirate-limit reale lato server, non solo lato client
Nuovo dispositivodeviceId in localStorage assente in RTDBregistrato al primo login riuscito da quel device
Admin non autorizzatotentativo di accesso alla sezione Admin senza permessorespinto subito, niente più box password "bucato"
DevTools apertieuristica su differenza tra outerWidth/innerWidthsolo per utenti non privilegiati, una notifica a sessione

Come effetto collaterale utile, lo stesso modulo di notifica è stato riusato per segnalare su Telegram i fallimenti delle automazioni schedulate esistenti (sincronizzazioni periodiche, webhook di stato ordini) — un'unica modifica a un modulo condiviso, invece di toccare sei file diversi uno per uno.

Pulizia finale: eliminare l'account invece di solo cambiare password

Con la migrazione conclusa, restava l'account Firebase condiviso originale. Cambiargli solo la password non basta: nessun punto del codice lo usa più, e le Regole di Firestore (separate da quelle del Realtime Database) verificavano solo request.auth != null, senza controllare quale utente — chi avesse ancora quella vecchia credenziale avrebbe comunque potuto autenticarsi e leggere/scrivere dati su Firestore. La soluzione più pulita è stata eliminare del tutto l'account da Firebase Authentication: un account che non serve più a nulla non ha bisogno di una password più sicura, ha bisogno di non esistere.

Cosa abbiamo imparato

  1. Un login "in interfaccia" non è sicurezza se le Regole del database non lo rispecchiano. Il vero perimetro sono le Regole, non il form che il browser mostra. Vale la pena verificarlo esplicitamente anche quando il login "sembra" funzionare correttamente.
  2. Cambiare identità Firebase a runtime invalida subito i permessi su tutto ciò che è già aperto. Le Regole vanno aggiornate in modo additivo appena il client comincia a usare la nuova identità, non rimandate alla fine della migrazione.
  3. Le Regole Firebase non filtrano parzialmente un nodo. Dati sensibili e non sensibili mescolati nello stesso nodo vanno separati prima di poter scrivere permessi granulari — non è un'operazione da improvvisare durante un debug in corso.
  4. Prima di restringere un nodo, verificare se è nel percorso di bootstrap dell'app. Un nodo letto al boot per tutti gli utenti, se ristretto per errore a un solo ruolo, blocca l'intera interfaccia per tutti gli altri — spesso senza un errore visibile, se il listener non ha un callback esplicito.
  5. Un rate-limit vero vive lato server. Un contatore solo client-side è azzerabile ricaricando la pagina o richiamando la funzione di login dalla console: il vero blocco deve stare in uno store che il client non può scrivere direttamente.
  6. Un account non più usato va eliminato, non solo rinnovato. Se le Regole di un secondo sistema (in questo caso Firestore) non controllano lo stesso claim, una vecchia credenziale valida resta una porta aperta finché l'account esiste.

Domande frequenti

Perché non basta cambiare la password dell'account Firebase condiviso?

Perché il problema non è quale password venga usata, ma il fatto che sia una sola, hardcoded nel sorgente client e condivisa da tutti. Il codice servito al browser non può mai essere davvero segreto: la nuova password sarebbe comunque pubblica nel nuovo bundle. Inoltre, con un'unica identità Firebase condivisa, le Regole del database non possono distinguere un operatore dall'altro. La soluzione richiede un'identità reale per ciascun operatore, non solo una password diversa.

Perché le Regole del database vanno aggiornate a metà migrazione e non solo alla fine?

Perché cambiare identità Firebase a runtime invalida immediatamente i permessi di tutto ciò che il client aveva già aperto sotto la vecchia identità. Appena il client comincia ad autenticarsi con il nuovo Custom Token, ogni listener realtime attivo viene ricontrollato contro le Regole esistenti: se autorizzano solo la vecchia identità condivisa, tutto smette di funzionare molto prima che la migrazione sia conclusa.

Le Regole di sicurezza Firebase possono proteggere solo una parte di un nodo?

No. Le Regole Realtime Database si applicano al nodo intero: se il client lo legge con un'unica chiamata, il permesso è concesso o negato per tutto il contenuto, non chiave per chiave. Dati sensibili mescolati a dati non sensibili nello stesso nodo vanno separati prima di poter restringere solo la parte sensibile.

Perché un nodo bloccato per errore ha reso l'intera app inutilizzabile invece di rompere solo una sezione?

Perché quel nodo era nell'elenco dei dati "critici" che l'app legge per intero prima di mostrare qualsiasi schermata, a prescindere dal ruolo. Bloccandone la lettura solo agli admin, ogni operatore non-admin restava bloccato su una lettura che non si sarebbe mai completata, senza errore visibile perché il listener non aveva un callback di errore esplicito.

Serve davvero un rate-limit lato server se esiste già un blocco dopo tentativi falliti nel client?

Sì. Un lockout solo client-side può essere azzerato ricaricando la pagina o richiamando la funzione di login direttamente dalla console del browser. Un rate-limit reale deve vivere lato server, con lo stato salvato in un nodo che il client non può scrivere direttamente.