Il contesto

[GestionaleOrdini] è un pannello interno per la gestione clienti e ordini, costruito interamente in vanilla JS con Firebase Realtime Database come backend. È usato in tempo reale da più operatori contemporaneamente: ogni modifica di un record si propaga via onValue() a tutte le sessioni aperte. I dati anagrafici — nome, email, telefono, note — vengono inseriti da operatori ma anche, in parte, da form di onboarding compilati dai clienti stessi.

L'audit è partito da una revisione sistematica del file principale, circa 3500 righe. Quello che è emerso non era un singolo problema isolato ma tre superfici di attacco distinte, ognuna con un livello di rischio e complessità di fix diverso.

Vulnerabilità #1 — XSS via innerHTML non sanitizzato

Tutta la tabella clienti viene costruita con template literal e assegnata direttamente all'innerHTML del contenitore. I dati anagrafici vengono interpolati così com'è:

renderTable — prima del fix
// ❌ Dati utente interpolati direttamente
rows += `<td class="col-nome" data-tip="${nome}" onclick="openViewModal('${id}')">${nome}</td>`;
rows += `<td class="col-email">${email}</td>`;
rows += `<td class="col-note">${nota}</td>`;

Se un cliente inserisce nel form di onboarding un nome come "><img src=x onerror=alert(1)>, quella stringa finisce identica nel DOM di tutti gli operatori connessi. Con dati provenienti da form esterni il rischio non è teorico: un payload XSS reale potrebbe leggere token di sessione, iniettare codice arbitrario o esfiltrare dati verso un endpoint remoto, il tutto sfruttando il fatto che Firebase diffonde la modifica in tempo reale a tutte le sessioni.

Il fix è stato aggiungere due helper vicino all'inizializzazione di Firebase, poi applicarli sistematicamente a ogni punto di interpolazione:

helper escapeHtml + escapeJsAttr
// Escape per contenuto HTML visibile (celle, body modali, note)
function escapeHtml(str) {
  return String(str ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// Escape aggiuntivo per valori dentro onclick='...' e data-tip=""
// Un apice singolo romperebbe la stringa JS anche dopo escapeHtml standard
function escapeJsAttr(str) {
  return escapeHtml(str).replace(/'/g, '\\\'');
}

// ✅ Dopo il fix
const eNome = escapeHtml(nome);
const eId   = escapeJsAttr(id);
rows += `<td class="col-nome" data-tip="${eNome}" onclick="openViewModal('${eId}')">${eNome}</td>`;

La distinzione tra i due helper è necessaria perché i contesti sono diversi: escapeHtml protegge il contenuto visibile delle celle e dei body dei modali, mentre escapeJsAttr gestisce il caso degli attributi onclick dove un apice singolo nel nome del cliente romperebbe la stringa JavaScript anche dopo un escape HTML normale. Il fix è stato applicato a tutti i punti di interpolazione: tabella principale, card mobile, modale di dettaglio, lista note e tabella del cestino.

💡

La sintassi valida dei due blocchi <script> è stata verificata con node --check prima del deploy. In un file HTML monolitico senza bundler, è l'unico modo rapido per escludere errori di sintassi JS introdotti durante le modifiche.

Vulnerabilità #2 — credenziali Firebase in chiaro nel sorgente client

Nel blocco di inizializzazione Firebase del file principale era presente questa riga:

prima del fix — credenziali visibili nel sorgente
// ❌ Email e password in chiaro — visibili con "Visualizza sorgente"
signInWithEmailAndPassword(auth, '[admin@example.com]', '[PASSWORD]');

Chiunque apra "Visualizza sorgente" su quella pagina ottiene le credenziali dell'account Firebase condiviso. Se le regole del Realtime Database sono ristrette a quell'auth.uid, quelle credenziali sono la chiave di accesso completo in lettura/scrittura all'intero database — bypassando completamente l'autenticazione HMAC di PanelControl.

La stessa sezione conteneva una seconda vulnerabilità correlata: la chiave HMAC usata per verificare i token emessi da PanelControl era anch'essa hardcoded in chiaro:

prima del fix — segreto HMAC esposto
// ❌ Chiunque legga il sorgente può forgiare token HMAC validi
var SECRET = '[HMAC_SECRET]';

// La stessa stringa era presente anche in PanelControl lato client

Il problema HMAC esposto è ancora più critico della password: chiunque conosca la chiave può forgiare da zero un token HMAC valido, bypassando PanelControl completamente senza nemmeno avere accesso all'account operatore.

La soluzione: Cloud Function mintAuthToken + Secret Manager

L'architettura del fix sposta tutta la logica sensibile server-side. Il client non deve più conoscere né la password Firebase né la chiave HMAC per scopi diversi dalla verifica preliminare UX. Il flusso diventa:

  1. PanelControl genera un token HMAC firmato con il segreto e lo passa all'URL del portale come parametro ?auth=...
  2. Il portale chiama la Cloud Function mintAuthToken passando il token
  3. La Cloud Function recupera il segreto HMAC da Secret Manager (mai nel codice), riverifica il token, e se valido chiama createCustomToken(uid)
  4. Il portale riceve il custom token e chiama signInWithCustomToken(auth, customToken) — nessuna password nel client
mintAuthToken — Cloud Function Node.js (2nd gen)
import { onRequest } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';
import admin from 'firebase-admin';
import crypto from 'crypto';

admin.initializeApp();

const hmacSecret = defineSecret('PANELCONTROL_HMAC_SECRET');

// UID fisso dell'account Firebase di servizio — non è un segreto
const FIXED_UID = '[UID_SERVICE_ACCOUNT]';

export const mintAuthToken = onRequest(
  { secrets: [hmacSecret], region: 'europe-west1' },
  async (req, res) => {
    // CORS manuale — Firebase v2 non supporta wildcard *.netlify.app
    res.set('Access-Control-Allow-Origin', '*');
    res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
    res.set('Access-Control-Allow-Headers', 'Content-Type');
    if (req.method === 'OPTIONS') { res.status(204).send(''); return; }

    const { token, timestamp, nonce } = req.body;
    if (!token || !timestamp || !nonce) {
      res.status(400).json({ error: 'missing fields' }); return;
    }

    // Token scaduto (finestra 5 minuti)
    if (Math.abs(Date.now() - Number(timestamp)) > 300_000) {
      res.status(401).json({ error: 'token expired' }); return;
    }

    // Verifica HMAC con il segreto da Secret Manager
    const secret = hmacSecret.value();
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}:${nonce}`)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
      res.status(401).json({ error: 'invalid token' }); return;
    }

    // Emette il custom token Firebase — nessuna password nel client
    const customToken = await admin.auth().createCustomToken(FIXED_UID);
    res.json({ customToken });
  }
);

Il segreto HMAC viene impostato una volta sola con firebase functions:secrets:set PANELCONTROL_HMAC_SECRET e non appare mai nel codice — né nella Cloud Function né nel client. La Cloud Function accede al valore a runtime tramite l'integrazione nativa di Firebase con Secret Manager.

Setup Firebase CLI su Windows (da zero)

Se non si ha Node.js installato, il primo ostacolo su Windows è la policy di esecuzione di PowerShell che blocca i file .ps1 di terze parti — incluso npm.ps1. L'installazione è tre comandi in sequenza:

PowerShell — setup completo
# 1. Sblocca l'esecuzione di script (richiede solo una volta)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

# 2. Installa Firebase CLI (dopo aver installato Node.js LTS da nodejs.org)
npm install -g firebase-tools

# 3. Login e init (risposta alle domande: JavaScript, ESLint → N, deps → Y)
firebase login
firebase init functions

# 4. Genera una chiave HMAC sicura (non riusare quella vecchia)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

# 5. Imposta il segreto (valore richiesto interattivamente — non appare in history)
firebase functions:secrets:set PANELCONTROL_HMAC_SECRET

# 6. Deploy
firebase deploy --only functions:mintAuthToken

Il muro del CORS con Cloud Functions v2

Dopo il primo deploy, il portale restituiva un errore Failed to fetch nella console con la Cloud Function raggiungibile ma che rifiutava la richiesta. Il problema era che Firebase Cloud Functions v2 non supporta il wildcard *.netlify.app nell'opzione cors del decorator onRequest. La soluzione è gestire CORS manualmente nella function, impostando gli header direttamente sulla risposta:

CORS — differenza tra cors:true e gestione manuale
// ❌ Non funziona con sottodomini dinamici Netlify (*.netlify.app)
onRequest({ cors: true, secrets: [hmacSecret] }, handler);

// ✅ Gestione manuale — funziona per qualsiasi origine
async (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
  res.set('Access-Control-Allow-Headers', 'Content-Type');
  // Risposta al preflight OPTIONS obbligatoria
  if (req.method === 'OPTIONS') { res.status(204).send(''); return; }
  // ... resto dell'handler
}
⚠️

Tra un deploy e l'altro l'URL della Cloud Function può cambiare — in particolare se Firebase rileva che la region o la configurazione è diversa. Dopo ogni deploy verificare l'URL nell'output e aggiornare la costante MINT_TOKEN_URL nel client se necessario. Nell'output del deploy compare come Function URL (mintAuthToken(europe-west1)): https://....

Permesso IAM mancante: Service Account Token Creator

Risolto il CORS, la Cloud Function veniva raggiunta ma restituiva HTTP 500. Il motivo: il service account di esecuzione di Cloud Functions — quello con il pattern [PROJECT_NUMBER]-compute@developer.gserviceaccount.com — non aveva il ruolo Service Account Token Creator nel pannello IAM di Google Cloud. Quel ruolo è necessario affinché il Firebase Admin SDK possa chiamare createCustomToken(), che internamente firma un JWT con la chiave privata del service account.

Il fix è una modifica IAM nella Console Google Cloud → IAM e amministrazione → IAM: cercare il compute service account, cliccare la matita, aggiungere il ruolo Service Account Token Creator, salvare. Nessun rideploy necessario — il permesso ha effetto immediato.

ruoli IAM necessari per mintAuthToken
// Service account: [PROJECT_NUMBER]-compute@developer.gserviceaccount.com
// Ruoli già presenti di default:
//   roles/firebase.sdkAdminServiceAgent
//   roles/secretmanager.secretAccessor  (aggiunto automaticamente dal deploy)

// Ruolo da aggiungere manualmente:
//   roles/iam.serviceAccountTokenCreator
//   → necessario per admin.auth().createCustomToken(uid)

Rotazione del segreto HMAC

Il segreto hardcoded nel sorgente originale deve essere considerato compromesso: chiunque abbia mai visualizzato il sorgente della pagina lo conosce. Non basta spostarlo in Secret Manager con il vecchio valore — bisogna generarne uno nuovo.

La rotazione coinvolge tre punti che devono essere aggiornati con il medesimo valore in modo atomico (altrimenti i login smettono di funzionare durante la transizione):

  1. Secret Managerfirebase functions:secrets:set PANELCONTROL_HMAC_SECRET → risponde Y alla domanda di redeploy
  2. [GestionaleOrdini] — sostituire il vecchio valore nella variabile SECRET lato client (usata solo per il feedback UX immediato del lock screen, non per la verifica effettiva)
  3. PanelControl — sostituire il valore nella variabile _CROSS_APP_SECRET in [app].js e ridepoyare su Netlify
🔴

Il segreto non deve mai passare via chat, email o strumenti di collaborazione — va inserito solo tramite firebase functions:secrets:set che lo richiede interattivamente senza mostrarlo, e aggiornato negli altri due posti manualmente nell'editor locale. La cronologia di qualsiasi chat che contenesse il valore diventa una superficie di attacco permanente.

Cosa rimane — architettura residua e debito tecnico

L'audit ha identificato due problemi strutturali che restano aperti ma a priorità inferiore:

  • Rebuild completo della tabella a ogni sync. renderTable ricostruisce l'intero innerHTML a ogni onValue('clienti'), cioè a ogni modifica di un singolo record da parte di qualsiasi operatore. Il debounce con requestAnimationFrame raggruppava le modifiche ravvicinate ma non elimina il costo del rebuild. Con un numero crescente di operatori o di record, il prossimo salto di performance richiederebbe diffing riga per riga — aggiornare solo il <tr> del cliente modificato invece dell'intera tabella.
  • transition: all residui. Una decina di elementi isolati usano ancora transition: all invece di proprietà specifiche. Impatto minimo su elementi non ripetuti, bassa priorità.

Cosa abbiamo imparato

  1. innerHTML + dati utente = XSS, sempre. In un'app realtime dove i dati vengono da form esterni, non c'è modo di sapere a priori cosa arriva. Due funzioni di escape centralizzate e applicate sistematicamente sono più affidabili di qualsiasi controllo a monte sul form di inserimento.
  2. Le credenziali nel sorgente client sono permanentemente esposte. Non esistono misure di offuscamento efficaci lato browser — il sorgente è sempre leggibile. La password Firebase e il segreto HMAC vanno tenuti server-side fin dall'inizio, non migrati quando il danno è già fatto.
  3. signInWithCustomToken è il pattern corretto per auth ibrida. Quando l'autenticazione primaria è gestita da un sistema esterno (PanelControl con HMAC), Firebase va usato solo come provider secondario: la Cloud Function fa da ponte sicuro senza esporre credenziali al client.
  4. CORS in Cloud Functions v2 richiede gestione manuale. Il parametro cors: true non funziona con sottodomini dinamici. Gestire gli header Access-Control-Allow-* manualmente e includere la risposta al preflight OPTIONS è la soluzione universale.
  5. Service Account Token Creator è un permesso non ovvio. Non viene concesso di default e non compare nell'errore in modo esplicito — un HTTP 500 generico che maschera un errore IAM interno. Va aggiunto manualmente dopo il primo deploy, una volta sola.
  6. Un segreto già esposto va ruotato, non spostato. Spostare il valore originale in Secret Manager senza cambiarlo non risolve il rischio: il vecchio valore è già visibile in chiunque abbia mai visualizzato il sorgente. La rotazione è parte integrante del fix.

Domande frequenti

Perché servono due funzioni separate — escapeHtml ed escapeJsAttr — invece di una sola?

escapeHtml sostituisce i cinque caratteri pericolosi in HTML (&, <, >, ", ') con le rispettive entity, ed è sufficiente per i testi inseriti come contenuto visibile nelle celle o nei body dei modali. escapeJsAttr serve invece per i valori interpolati dentro attributi onclick='...', dove un apice singolo nel testo romperebbe la stringa JavaScript anche dopo un escape HTML normale. Le due funzioni si combinano: in un attributo onclick il dato viene prima passato attraverso escapeJsAttr (che scappa l'apice) e poi l'intera stringa attraverso escapeHtml per proteggere l'attributo HTML.

Perché non usare textContent ovunque invece di innerHTML, evitando il problema alla radice?

Per contenuto puramente testuale, textContent è sempre preferibile. Ma in un componente che costruisce intere righe di tabella o card mobile in una singola stringa HTML, il mix di struttura (td, span, button con onclick) e dati richiede necessariamente template literal con innerHTML. L'escape separa le due responsabilità: la struttura HTML è nostra, i dati utente no.

Perché la Cloud Function restituiva 500 dopo aver risolto il CORS?

Il service account di esecuzione delle Cloud Functions non aveva il ruolo "Service Account Token Creator" nel pannello IAM di Google Cloud. Quel ruolo è necessario affinché il Firebase Admin SDK possa chiamare createCustomToken() — operazione che richiede la firma di un JWT con la chiave privata del service account. Senza il ruolo, la chiamata interna fallisce con un errore di permesso che si manifesta come HTTP 500 lato client.

Se la Cloud Function non è raggiungibile, il gestionale smette di funzionare del tutto?

Sì, nella nuova architettura la Cloud Function è nel percorso critico del login. Il vantaggio è che gira su Google Cloud Run con alta disponibilità e avvio a freddo rapido. Il rischio operativo è comunque inferiore rispetto a tenere la password Firebase in chiaro nel sorgente client, che rappresenta una vulnerabilità permanente e non mitigabile.

Bisogna ridepoyare la Cloud Function quando si ruota il segreto in Secret Manager?

No, se si usa firebase functions:secrets:set e si risponde Y alla domanda di redeploy. Firebase rideploya automaticamente la function con la nuova versione del segreto e revoca quella vecchia. Il client non deve essere aggiornato: solo il valore del segreto cambia lato server.