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.
The context
[GestionaleOrdini] is an internal panel for customer and order management, built entirely in vanilla JS with Firebase Realtime Database as the backend. It is used in real time by multiple operators simultaneously: every record change propagates via onValue() to all open sessions. Personal data — name, email, phone, notes — is entered by operators but also, in part, from onboarding forms filled in by customers themselves.
The audit started from a systematic review of the main file, roughly 3,500 lines. What emerged was not a single isolated problem but three distinct attack surfaces, each with a different risk level and fix complexity.
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'è:
Vulnerability #1 — XSS via unsanitised innerHTML
The entire customer table is built with template literals and assigned directly to the container's innerHTML. Personal data is interpolated as-is:
// ❌ 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:
If a customer enters a name like "><img src=x onerror=alert(1)> in the onboarding form, that string lands unchanged in the DOM of every connected operator. With data coming from external forms the risk is not theoretical: a real XSS payload could read session tokens, inject arbitrary code or exfiltrate data to a remote endpoint, all by exploiting Firebase's real-time propagation to every open session.
The fix was adding two helpers near the Firebase initialisation, then applying them systematically to every interpolation point:
// Escape per contenuto HTML visibile (celle, body modali, note)
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 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.
The distinction between the two helpers is necessary because the contexts are different: escapeHtml protects the visible content of cells and modal bodies, while escapeJsAttr handles the case of onclick attributes where a single quote in a customer name would break the JavaScript string even after a normal HTML escape. The fix was applied to all interpolation points: main table, mobile cards, detail modal, notes list and trash table.
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:
Vulnerability #2 — Firebase credentials in plain text in the client source
In the Firebase initialisation block of the main file there was this line:
// ❌ 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:
Anyone opening "View source" on that page obtains the shared Firebase account credentials. If the Realtime Database rules are restricted to that auth.uid, those credentials are the full read/write access key to the entire database — completely bypassing PanelControl's HMAC authentication.
The same section contained a second related vulnerability: the HMAC key used to verify tokens issued by PanelControl was also hardcoded in plain text:
// ❌ 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.
The exposed HMAC problem is even more critical than the password: anyone who knows the key can forge a valid HMAC token from scratch, bypassing PanelControl entirely without even having access to an operator account.
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:
- PanelControl genera un token HMAC firmato con il segreto e lo passa all'URL del portale come parametro
?auth=... - Il portale chiama la Cloud Function
mintAuthTokenpassando il token - La Cloud Function recupera il segreto HMAC da Secret Manager (mai nel codice), riverifica il token, e se valido chiama
createCustomToken(uid) - Il portale riceve il custom token e chiama
signInWithCustomToken(auth, customToken)— nessuna password nel client
The solution: Cloud Function mintAuthToken + Secret Manager
The fix architecture moves all sensitive logic server-side. The client no longer needs to know either the Firebase password or the HMAC key for anything beyond a preliminary UX check. The flow becomes:
- PanelControl generates an HMAC-signed token with the secret and passes it in the portal URL as a
?auth=...parameter - The portal calls the Cloud Function
mintAuthTokenpassing the token - The Cloud Function retrieves the HMAC secret from Secret Manager (never in code), re-verifies the token, and if valid calls
createCustomToken(uid) - The portal receives the custom token and calls
signInWithCustomToken(auth, customToken)— no password in the client
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.
The HMAC secret is set just once with firebase functions:secrets:set PANELCONTROL_HMAC_SECRET and never appears in code — neither in the Cloud Function nor in the client. The Cloud Function accesses the value at runtime through Firebase's native Secret Manager integration.
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:
Firebase CLI setup on Windows (from scratch)
Without Node.js installed, the first obstacle on Windows is PowerShell's execution policy that blocks third-party .ps1 files — including npm.ps1. The setup is three commands in sequence:
# 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:
The CORS wall with Cloud Functions v2
After the first deploy, the portal returned a Failed to fetch error in the console with the Cloud Function reachable but rejecting the request. The problem was that Firebase Cloud Functions v2 does not support the *.netlify.app wildcard in the cors option of the onRequest decorator. The solution is to handle CORS manually in the function, setting the headers directly on the response:
// ❌ 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.
Missing IAM permission: Service Account Token Creator
With CORS fixed, the Cloud Function was being reached but returning HTTP 500. The reason: the Cloud Functions execution service account — the one with the pattern [PROJECT_NUMBER]-compute@developer.gserviceaccount.com — did not have the Service Account Token Creator role in the Google Cloud IAM panel. That role is needed for the Firebase Admin SDK to call createCustomToken(), which internally signs a JWT with the service account's private key.
The fix is an IAM change in the Google Cloud Console → IAM & Admin → IAM: find the compute service account, click the pencil icon, add the Service Account Token Creator role, save. No redeploy needed — the permission takes effect immediately.
// 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):
- Secret Manager —
firebase functions:secrets:set PANELCONTROL_HMAC_SECRET→ risponde Y alla domanda di redeploy - [GestionaleOrdini] — sostituire il vecchio valore nella variabile
SECRETlato client (usata solo per il feedback UX immediato del lock screen, non per la verifica effettiva) - PanelControl — sostituire il valore nella variabile
_CROSS_APP_SECRETin[app].jse ridepoyare su Netlify
HMAC secret rotation
The secret hardcoded in the original source must be considered compromised: anyone who has ever viewed the page source knows it. It is not enough to move it to Secret Manager with the old value — a new one must be generated.
The rotation involves three points that must be updated with the same value atomically (otherwise logins stop working during the transition):
- Secret Manager —
firebase functions:secrets:set PANELCONTROL_HMAC_SECRET→ answer Y to the redeploy question - [GestionaleOrdini] — replace the old value in the client-side
SECRETvariable (used only for the lock screen's immediate UX feedback, not for the actual verification) - PanelControl — replace the value in the
_CROSS_APP_SECRETvariable in[app].jsand redeploy to 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.
renderTablericostruisce l'interoinnerHTMLa ognionValue('clienti'), cioè a ogni modifica di un singolo record da parte di qualsiasi operatore. Il debounce conrequestAnimationFrameraggruppava 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: allinvece di proprietà specifiche. Impatto minimo su elementi non ripetuti, bassa priorità.
What remains — residual architecture and technical debt
The audit identified two structural problems that remain open but at lower priority:
- Full table rebuild on every sync.
renderTablerebuilds the entireinnerHTMLon everyonValue('clienti'), meaning every time any operator modifies any single record. The debounce withrequestAnimationFramegroups close-together changes but does not eliminate the rebuild cost. With a growing number of operators or records, the next performance leap would require per-row diffing — updating only the modified customer's<tr>instead of the entire table. - Residual transition: all. Around ten isolated elements still use
transition: allinstead of specific properties. Minimal impact on non-repeated elements, low priority.
Cosa abbiamo imparato
- 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.
- 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.
- 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.
-
CORS in Cloud Functions v2 richiede gestione manuale. Il parametro
cors: truenon funziona con sottodomini dinamici. Gestire gli headerAccess-Control-Allow-*manualmente e includere la risposta al preflight OPTIONS è la soluzione universale. - 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.
- 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.
What we learned
- innerHTML + user data = XSS, always. In a realtime app where data comes from external forms, there is no way to know in advance what will arrive. Two centralised escape functions applied systematically are more reliable than any upstream validation on the entry form.
- Credentials in client source are permanently exposed. No effective obfuscation measures exist in the browser — source is always readable. Firebase password and HMAC secret must be kept server-side from the start, not migrated after the damage is already done.
- signInWithCustomToken is the correct pattern for hybrid auth. When primary authentication is handled by an external system (PanelControl with HMAC), Firebase should only be used as a secondary provider: the Cloud Function acts as a secure bridge without exposing credentials to the client.
-
CORS in Cloud Functions v2 requires manual handling. The
cors: trueparameter does not work with dynamic subdomains. Manually settingAccess-Control-Allow-*headers and including a response to the OPTIONS preflight is the universal solution. - Service Account Token Creator is a non-obvious permission. It is not granted by default and does not appear explicitly in the error message — a generic HTTP 500 that masks an internal IAM error. It must be added manually after the first deploy, just once.
- An already-exposed secret must be rotated, not just moved. Moving the original value to Secret Manager without changing it does not solve the risk: the old value is already visible to anyone who has ever viewed the source. Rotation is an integral part of the 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.
Frequently asked questions
Why do you need two separate functions — escapeHtml and escapeJsAttr — instead of just one?
escapeHtml replaces the five dangerous HTML characters (&, <, >, ", ') with their entity equivalents, and is sufficient for text inserted as visible content in cells or modal bodies. escapeJsAttr is needed for values interpolated inside onclick='...' attributes, where a single quote in the text would break the JavaScript string even after normal HTML escaping. The two functions combine: in an onclick attribute the value is first passed through escapeJsAttr (which escapes the single quote) and then the whole string through escapeHtml to protect the HTML attribute.
Why not use textContent everywhere instead of innerHTML, avoiding the problem at the root?
For purely textual content, textContent is always preferable. But in a component that builds entire table rows or mobile cards as a single HTML string, the mix of structure (td, span, button with onclick) and data necessarily requires template literals with innerHTML. Escaping separates the two responsibilities: the HTML structure is ours, user data is not.
Why did the Cloud Function return 500 after fixing CORS?
The Cloud Functions execution service account did not have the "Service Account Token Creator" role in the Google Cloud IAM panel. That role is needed for the Firebase Admin SDK to call createCustomToken() — an operation that requires signing a JWT with the service account's private key. Without the role, the internal call fails with a permission error that surfaces as HTTP 500 on the client.
If the Cloud Function is unreachable, does the management panel stop working entirely?
Yes, in the new architecture the Cloud Function is in the critical login path. The advantage is that it runs on Google Cloud Run with high availability and fast cold start. The operational risk is still lower than keeping the Firebase password in plain text in the client source, which represents a permanent and unmitigable vulnerability.
Do you need to redeploy the Cloud Function when rotating the secret in Secret Manager?
No, if you use firebase functions:secrets:set and answer Y to the redeploy question. Firebase automatically redeploys the function with the new secret version and revokes the old one. The client does not need to be updated: only the secret value changes on the server side.