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.

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:

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>`;

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:

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>`;

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.

Vulnerability #2 — Firebase credentials in plain text in the client source

In the Firebase initialisation block of the main file there was this line:

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

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:

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

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.

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:

  1. PanelControl generates an HMAC-signed token with the secret and passes it in the portal URL as a ?auth=... parameter
  2. The portal calls the Cloud Function mintAuthToken passing the token
  3. The Cloud Function retrieves the HMAC secret from Secret Manager (never in code), re-verifies the token, and if valid calls createCustomToken(uid)
  4. The portal receives the custom token and calls signInWithCustomToken(auth, customToken) — no password in the 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 });
  }
);

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.

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:

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

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:

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://....

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.

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)

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):

  1. Secret Managerfirebase functions:secrets:set PANELCONTROL_HMAC_SECRET → answer Y to the redeploy question
  2. [GestionaleOrdini] — replace the old value in the client-side SECRET variable (used only for the lock screen's immediate UX feedback, not for the actual verification)
  3. PanelControl — replace the value in the _CROSS_APP_SECRET variable in [app].js and 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.

When a security audit like this is needed (and when it's not)

When to do it

  • Production apps with real data, even for internal use: XSS vulnerabilities and exposed credentials don't distinguish between "team only" and "public".
  • Before opening access to more people or externals to an app that started as an internal prototype: this is the natural moment to validate there are no gaps inherited from the rapid development phase.
  • After a major architectural change (e.g. introducing a Custom Token, a new Cloud Function): every new component is a potential attack surface to verify.

When it's not the immediate priority

  • Local prototype never exposed, used only on localhost for testing: the real risk is close to zero as long as there's no public deploy.
  • App already through a recent audit with no substantial code changes since: repeating the same audit right away has low return.

Alternatives

For teams without time for an in-depth manual audit, automated tools like OWASP ZAP or Snyk can catch some common vulnerabilities (XSS, vulnerable dependencies), but they don't replace a targeted review of the specific architecture, like the Firebase credential handling discussed here.

Common mistakes

  • Assuming innerHTML is always safe when the content comes, even indirectly, from user input: any value not explicitly sanitized is a potential XSS vector.
  • Leaving Firebase credentials in plain text in client source thinking "they're just public keys anyway": they still need to be protected with robust server-side security rules, since the public key alone identifies the project but shouldn't be enough for sensitive operations.
  • Forgetting the specific IAM permission (e.g. Service Account Token Creator) required by features like Custom Token generation — an error that only surfaces at usage time, not during deploy.

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. renderTable rebuilds the entire innerHTML on every onValue('clienti'), meaning every time any operator modifies any single record. The debounce with requestAnimationFrame groups 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: all instead of specific properties. Minimal impact on non-repeated elements, low priority.

What we learned

  1. 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.
  2. 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.
  3. 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.
  4. CORS in Cloud Functions v2 requires manual handling. The cors: true parameter does not work with dynamic subdomains. Manually setting Access-Control-Allow-* headers and including a response to the OPTIONS preflight is the universal solution.
  5. 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.
  6. 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.

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.