Il Cross-Site Scripting (XSS) è la vulnerabilità web più segnalata nel 2025 secondo OWASP, presente nel 68% delle applicazioni testate. Con Node.js e Express.js, un singolo campo di input non protetto è sufficiente affinché un aggressore inietti codice JavaScript malevolo, rubi i cookie di sessione degli utenti, esegua azioni per conto loro o installi malware. Questa guida ti mostra come prevenire l’XSS in Node.js in 12 step pratici, con codice funzionante, i pacchetti npm giusti e una checklist completa per la produzione.

Cosa Sono gli Attacchi XSS e Perché Node.js è a Rischio

Un attacco Cross-Site Scripting (XSS) si verifica quando un aggressore riesce a iniettare codice JavaScript in una pagina web visualizzata da altri utenti. Il browser della vittima esegue quel codice perché lo percepisce come parte legittima della pagina. Il danno può variare dal furto di cookie di sessione (e quindi dell’accesso all’account) alla reindirizzazione su siti di phishing, all’esecuzione di keylogger nel browser.

Node.js e Express.js non sono intrinsecamente più vulnerabili di altri framework, ma la natura dinamica di JavaScript, la facilità con cui si concatenano stringhe HTML e la mancanza di un sistema di template sicuro di default espongono molti sviluppatori a rischi evitabili. Un tipico errore è restituire direttamente all’utente dati non sanitizzati prelevati dal database o dai parametri della richiesta HTTP.

Esistono tre categorie principali di XSS, ciascuna con caratteristiche e vettori di attacco distinti. Comprenderle è il primo passo verso una protezione efficace.

Tipo di XSSCome FunzionaPersistenzaEsempio
Stored XSSIl payload viene salvato nel database e servito a tutti gli utentiPermanenteCommento con <script> in un forum
Reflected XSSIl payload viene incluso nella richiesta e rimbalzato nella rispostaTemporaneaParametro URL con codice malevolo
DOM-Based XSSIl payload modifica il DOM lato client tramite JavaScriptVariabiledocument.write(location.hash)
Blind XSSIl payload viene eseguito in aree non visibili all’aggressorePermanenteCampo di log visualizzato dall’amministratore
mXSS (Mutation XSS)Il browser muta il markup sanitizzato in codice eseguibileVariabileParsing anomalo di tag annidati

Prerequisiti per Seguire il Tutorial

Prima di iniziare, assicurati di avere installato nel tuo ambiente di sviluppo:

  • Node.js 20.x LTS o superiore (verificare con node --version)
  • npm 10.x o superiore (verificare con npm --version)
  • Express.js 4.x installato nel progetto
  • Un editor di testo come VS Code con il plugin ESLint configurato
  • Conoscenza base di JavaScript e del protocollo HTTP
  • Terminale con accesso a riga di comando

Il tutorial usa Node.js 20 LTS perché è la versione supportata a lungo termine attiva nel 2026 e include moduli di sicurezza maturi. Puoi seguire le stesse istruzioni su Node.js 22.x senza modifiche sostanziali al codice.

Step 1: Creare il Progetto e Riprodurre la Vulnerabilità XSS

Prima di implementare la protezione, è utile vedere concretamente come si manifesta una vulnerabilità XSS. Creiamo un’applicazione Express.js volutamente vulnerabile per capire il problema dal vivo.

mkdir xss-demo && cd xss-demo
npm init -y
npm install express ejs

# Struttura del progetto
xss-demo/
├── server.js
├── views/
│   └── index.ejs
└── package.json

Crea un file server.js con un semplice endpoint vulnerabile per vedere l’attacco in azione:

// server.js - VERSIONE VULNERABILE (solo per dimostrazione, non usare in produzione)
const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// VULNERABILE: riflette il parametro "q" senza sanitizzazione alcuna
app.get('/search', (req, res) => {
  const query = req.query.q || '';
  // MAI fare così: concatenare HTML con input utente non sanitizzato
  res.send(`<h1>Risultati per: ${query}</h1>`);
});

// VULNERABILE: salva e restituisce commenti HTML senza sanitizzazione
const comments = [];
app.post('/comment', (req, res) => {
  comments.push(req.body.text); // salva HTML grezzo
  res.json({ ok: true });
});

app.get('/comments', (req, res) => {
  // MAI fare così: renderizzare HTML grezzo dall'utente
  res.send(comments.map(c => `<p>${c}</p>`).join(''));
});

app.listen(3000, () => console.log('Server vulnerabile attivo sulla porta 3000'));

Se avvii questo server e visiti http://localhost:3000/search?q=<script>alert('XSS')</script>, vedrai un popup di alert nel browser. Questo dimostra che il codice JavaScript iniettato viene eseguito nel contesto della tua applicazione. Nei passi seguenti correggeremo ogni singola vulnerabilità con strumenti professionali.

Step 2: Installare le Dipendenze di Sicurezza per la Prevenzione XSS

L’ecosistema npm offre diversi pacchetti specifici per prevenire l’XSS in Node.js. Ogni pacchetto ha un ruolo preciso e vanno usati in combinazione per una protezione a livelli.

# Installa le dipendenze di sicurezza principali
npm install helmet sanitize-html express-validator xss express-session

# Dipendenze di sviluppo per l'analisi statica del codice
npm install --save-dev eslint eslint-plugin-security nodemon

# Verifica le vulnerabilità note nelle dipendenze
npm audit --audit-level=moderate
Pacchetto npmFunzioneQuando Usarlo
helmetImposta le HTTP security headers inclusa la CSPSempre, in ogni app Express.js
sanitize-htmlSanitizza HTML consentendo solo tag sicuri configurabiliQuando si accettano input HTML ricchi (blog, commenti)
express-validatorValida e sanitizza i campi della richiesta HTTPValidazione di form, API e parametri URL
xssFiltro XSS basato su whitelist per HTMLAlternativa leggera a sanitize-html
express-sessionGestione sessioni con cookie sicuriApplicazioni con autenticazione utente
eslint-plugin-securityRileva pattern di codice vulnerabili a XSS durante lo sviluppoIn ogni progetto come controllo statico

Esegui npm audit come parte della pipeline CI/CD per rilevare vulnerabilità note prima del deploy. GitHub Dependabot e Snyk possono automatizzare questo controllo su ogni pull request, avvisandoti quando viene pubblicata una patch di sicurezza per le dipendenze del progetto.

Step 3: Configurare Helmet.js con la Content Security Policy

Helmet.js è il middleware Express più importante per la sicurezza delle intestazioni HTTP. Con una singola invocazione, imposta oltre 11 header che istruiscono il browser su come gestire le risorse della pagina, riducendo drasticamente la superficie d’attacco per XSS e altri attacchi lato client.

La Content Security Policy (CSP) è lo strumento più efficace contro l’XSS a livello di browser: definisce esplicitamente quali fonti di script, stili e altri contenuti sono autorizzate. Anche se un aggressore riesce ad iniettare un tag <script>, il browser lo blocca se la fonte non è nella whitelist CSP. La CSP riduce l’impatto di XSS anche quando la sanitizzazione fallisce.

// server.js - Configurazione Helmet con CSP basata su nonce
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');

const app = express();

// Genera un nonce crittograficamente sicuro per ogni richiesta HTTP
app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

// Configurazione Helmet con CSP dinamica basata su nonce
app.use((req, res, next) => {
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: [
          "'self'",
          // Consenti solo script con il nonce corretto per questa richiesta
          (req, res) => `'nonce-${res.locals.nonce}'`,
        ],
        styleSrc:    ["'self'", "'unsafe-inline'"],
        imgSrc:      ["'self'", "data:", "https:"],
        connectSrc:  ["'self'"],
        fontSrc:     ["'self'"],
        objectSrc:   ["'none'"],     // blocca plugin Flash e oggetti incorporati
        frameSrc:    ["'none'"],     // blocca iframe non autorizzati (anti-clickjacking)
        baseUri:     ["'self'"],     // impedisce injection del tag <base>
        formAction:  ["'self'"],     // limita le destinazioni dei form
        upgradeInsecureRequests: [], // forza HTTPS per le risorse HTTP
      },
      reportOnly: false,
    },
    xXssProtection: false, // disabilita l'header obsoleto X-XSS-Protection
    crossOriginEmbedderPolicy: false,
  })(req, res, next);
});

app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));

app.listen(3000, () => console.log('Server con Helmet attivo sulla porta 3000'));

Una nota importante: xXssProtection: false non è un errore di configurazione. L’header X-XSS-Protection è stato rimosso da Chrome, Firefox e Edge perché creava nuovi vettori di attacco invece di prevenirli. La documentazione ufficiale di Helmet consiglia esplicitamente di disabilitarlo. La protezione moderna si basa esclusivamente su CSP.

Step 4: Validare l’Input con express-validator

La validazione dell’input è la prima linea di difesa: rifiuta i dati che non corrispondono al formato atteso prima ancora che raggiungano la logica dell’applicazione. express-validator è la libreria standard per Express e si integra perfettamente con i middleware di route.

Il principio fondamentale è chiaro: non esiste un “input sicuro” di default. Ogni dato proveniente dall’esterno, inclusi parametri URL, header HTTP, cookie e body della richiesta, va trattato come potenzialmente malevolo. Questo vale anche per i dati provenienti da API di terze parti o dal proprio database.

const { body, query, param, validationResult } = require('express-validator');

// Middleware centralizzato per gestire gli errori di validazione
const handleValidation = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      errors: errors.array().map(e => ({
        field: e.path,
        message: e.msg
      }))
    });
  }
  next();
};

// Route con validazione completa per un commento utente
app.post('/comment',
  [
    body('text')
      .trim()                              // rimuovi spazi iniziali e finali
      .notEmpty().withMessage('Il testo è obbligatorio')
      .isLength({ min: 1, max: 500 }).withMessage('Massimo 500 caratteri')
      .escape(),                           // converte <, >, &, ", ' in entità HTML

    body('author')
      .trim()
      .notEmpty().withMessage('Il nome è obbligatorio')
      .isAlphanumeric('it-IT', { ignore: ' -' })
      .withMessage('Solo lettere, numeri, spazi e trattini')
      .isLength({ min: 2, max: 50 }),

    body('website')
      .optional()
      .isURL({ protocols: ['https'], require_tld: true })
      .withMessage('Solo URL HTTPS validi'), // blocca javascript: e vbscript:
  ],
  handleValidation,
  (req, res) => {
    const { text, author } = req.body;
    // text è già escapato grazie a .escape()
    res.json({ ok: true, comment: { text, author } });
  }
);

// Route con validazione del parametro URL numerico
app.get('/user/:id',
  [
    param('id')
      .isInt({ min: 1 }).withMessage('ID non valido')
      .toInt(),                            // converte in numero intero sicuro
  ],
  handleValidation,
  (req, res) => {
    const userId = req.params.id; // è già un intero, non una stringa
    res.json({ userId });
  }
);

// Validazione parametri di ricerca con sanitizzazione personalizzata
app.get('/search',
  [
    query('q')
      .trim()
      .isLength({ max: 200 }).withMessage('Ricerca troppo lunga')
      .customSanitizer(value => value.replace(/[<>"'`]/g, '')),
  ],
  handleValidation,
  (req, res) => {
    res.json({ results: [], query: req.query.q });
  }
);

Il metodo .escape() di express-validator converte i caratteri speciali HTML in entità sicure: < diventa &lt;, > diventa &gt;, & diventa &amp;. Questo approccio è ideale per i campi di testo puro. Se invece devi accettare HTML formattato (grassetto, liste, link), usa sanitize-html come mostrato nel passo successivo.

Step 5: Sanitizzare l’HTML con sanitize-html

Alcune applicazioni, come blog o editor di contenuti, devono accettare HTML dagli utenti. In questi casi l’escaping totale non è praticabile: rimuoverebbe tutti i tag legittimi come <b>, <i> o <a>. La soluzione è la sanitizzazione: mantieni solo i tag e gli attributi dalla whitelist, rimuovi tutto il resto incluso qualsiasi JavaScript.

const sanitizeHtml = require('sanitize-html');

// Whitelist permissiva per editor di testo ricco (articoli del blog)
const RICH_TEXT_OPTIONS = {
  allowedTags: [
    'b', 'i', 'em', 'strong', 'u', 's',
    'p', 'br', 'ul', 'ol', 'li',
    'h1', 'h2', 'h3', 'blockquote',
    'a', 'code', 'pre'
  ],
  allowedAttributes: {
    'a': ['href', 'title', 'rel'],
    'code': ['class'],
    'pre': ['class']
  },
  allowedSchemes: ['https', 'mailto'],  // blocca javascript: e vbscript:
  allowedSchemesByTag: {
    'a': ['https', 'mailto']
  },
  disallowedTagsMode: 'discard',
  enforceHtmlBoundary: true,
  transformTags: {
    'a': (tagName, attribs) => ({
      tagName: 'a',
      attribs: {
        href: attribs.href || '#',
        title: attribs.title || '',
        rel: 'noopener noreferrer nofollow',
        target: '_blank'
      }
    })
  }
};

// Whitelist restrittiva per commenti degli utenti
const COMMENT_OPTIONS = {
  allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
  allowedAttributes: {},
  allowedSchemes: [],
};

// Route che accetta HTML ricco per articoli
app.post('/article',
  body('content').trim().notEmpty().isLength({ max: 50000 }),
  handleValidation,
  (req, res) => {
    const rawHtml = req.body.content;

    // Sanitizza prima di salvare nel database
    const cleanHtml = sanitizeHtml(rawHtml, RICH_TEXT_OPTIONS);

    // Controlla che la sanitizzazione non abbia prodotto contenuto vuoto
    if (cleanHtml.trim().length === 0 && rawHtml.trim().length > 0) {
      return res.status(400).json({ error: 'Contenuto non valido o interamente rimosso dalla sanitizzazione' });
    }

    res.json({ ok: true, bytesSaved: cleanHtml.length });
  }
);

// Route per commenti con sanitizzazione minimale
app.post('/comment-html',
  body('text').trim().notEmpty().isLength({ max: 500 }),
  handleValidation,
  (req, res) => {
    const clean = sanitizeHtml(req.body.text, COMMENT_OPTIONS);
    res.json({ ok: true, text: clean });
  }
);

Un aspetto critico spesso trascurato: la sanitizzazione va applicata sia al momento della scrittura nel database che al momento della lettura. Sanitizzare solo all’uscita significa che dati corrotti nel database (magari inseriti prima di implementare la protezione) vengono comunque serviti all’utente. La best practice è sanitizzare in entrata e applicare output encoding in uscita.

Step 6: Implementare l’Output Encoding Contestuale

L’output encoding è diverso dalla sanitizzazione: non rimuove i dati potenzialmente pericolosi, li trasforma in una rappresentazione sicura per il contesto specifico in cui vengono visualizzati. Il contesto è fondamentale: il carattere < in HTML richiede &lt;, ma in un attributo JavaScript o in un URL richiede un encoding completamente diverso.

// Funzione di escaping HTML manuale (utile senza dipendenze esterne)
function escapeHtml(text) {
  if (typeof text !== 'string') return '';
  return text
    .replace(/&/g, '&amp;')     // deve essere PRIMA degli altri replace
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&/');    // protezione aggiuntiva per contesti inline
}

// Escaping sicuro per incorporare dati JSON in HTML
// (es. passare dati dal server al client via <script>)
function safeJsonEmbed(data) {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')      // < potrebbe chiudere tag <script>
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/'/g, '\\u0027');
}

// Validazione degli URL per evitare "javascript:" e "vbscript:"
function sanitizeUrl(url) {
  if (!url) return '#';
  try {
    const parsed = new URL(url);
    if (!['https:', 'http:', 'mailto:'].includes(parsed.protocol)) {
      return '#'; // protocollo pericoloso
    }
    return parsed.href;
  } catch {
    return '#'; // URL non valido
  }
}

// Esempio di route sicura con EJS
// EJS: <%= variabile %> applica escaping HTML automatico
// EJS: <%- variabile %> restituisce HTML grezzo (usare SOLO con contenuto già sanitizzato)
app.get('/profile/:username', (req, res) => {
  const user = {
    name: req.params.username,
    bio: '<script>alert("XSS")</script> Sviluppatore',
    website: 'javascript:alert(1)',
    jsonData: { role: 'user', count: 42 }
  };

  res.render('profile', {
    name: user.name,                     // EJS escaperà con <%= name %>
    bio: escapeHtml(user.bio),           // doppio escaping per sicurezza
    website: sanitizeUrl(user.website),  // '#' per URL pericolosi
    // Per JSON inline in script tag:
    jsonEmbed: safeJsonEmbed(user.jsonData)
    // Usare nel template: <script nonce="<%= nonce %>">var d=<%-jsonEmbed%></script>
  });
});

// Sempre usa res.json() per le API REST: imposta Content-Type: application/json
// e serializza correttamente, prevenendo injection di HTML nella risposta
app.get('/api/profile', (req, res) => {
  res.json({ name: 'Mario', role: 'admin' });
});

L’obiettivo principale di molti attacchi XSS è il furto dei cookie di sessione. Una volta che un aggressore ottiene il cookie session_id, può impersonare l’utente senza conoscerne le credenziali. Il flag HttpOnly istruisce il browser a non esporre il cookie a JavaScript: anche se un payload XSS viene eseguito, non può leggere i cookie di sessione.

const session = require('express-session');
const crypto = require('crypto');

// Configurazione sicura di express-session
app.use(session({
  // Il segreto va salvato in una variabile d'ambiente, mai nel codice sorgente
  secret: process.env.SESSION_SECRET || crypto.randomBytes(64).toString('hex'),

  // Il prefisso __Host- impone: Secure=true, nessun attributo Domain, Path=/
  // Questo impedisce a sottodomini compromessi di sovrascrivere il cookie
  name: '__Host-session',

  resave: false,
  saveUninitialized: false,

  cookie: {
    httpOnly: true,       // JavaScript non può leggere questo cookie
    secure: true,         // invia il cookie solo su HTTPS
    sameSite: 'strict',   // blocca l'invio cross-site (protezione CSRF)
    maxAge: 7200000,      // 2 ore in millisecondi
    path: '/',
  }
}));

// Prevenzione session fixation: rigenera il session ID dopo il login
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = await verificaCredenziali(username, password);

  if (!user) {
    return res.status(401).json({ error: 'Credenziali non valide' });
  }

  // Rigenera il session ID per prevenire session fixation attacks
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: 'Errore sessione' });
    req.session.userId = user.id;
    req.session.role = user.role;
    req.session.loginAt = Date.now();
    res.json({ ok: true });
  });
});

// Logout sicuro: distruggi la sessione e pulisci il cookie
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    res.clearCookie('__Host-session');
    res.json({ ok: true });
  });
});

Il flag sameSite: 'strict' aggiunge una difesa contro gli attacchi CSRF (Cross-Site Request Forgery) in aggiunta alla protezione XSS: il browser non invia il cookie nelle richieste cross-site, rendendo più difficile per un aggressore sfruttare una sessione attiva da un sito diverso. Per le API REST che devono essere chiamate da altri domini, usa sameSite: 'none' con secure: true e implementa token CSRF espliciti.

Step 8: Configurare la CSP con Nonce per Script Inline

La direttiva 'unsafe-inline' nella CSP neutralizza gran parte della protezione contro XSS perché consente qualsiasi script inline. La soluzione moderna si chiama nonce: un valore casuale unico generato per ogni richiesta HTTP. Solo gli script che includono il nonce corretto nell’attributo nonce vengono eseguiti dal browser. Un payload XSS iniettato non conosce il nonce e viene bloccato.

// Middleware completo per CSP con nonce e report URI
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64url');
  res.locals.nonce = nonce;

  // Imposta la CSP per questa specifica risposta HTTP
  const cspDirectives = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}'`,   // solo script con questo nonce univoco
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `font-src 'self'`,
    `connect-src 'self'`,
    `frame-ancestors 'none'`,               // blocca il sito in iframe
    `object-src 'none'`,
    `base-uri 'self'`,                      // blocca injection del tag <base>
    `form-action 'self'`,                   // limita dove possono inviare i form
    `upgrade-insecure-requests`,
    `report-uri /csp-report`,               // ricevi le violazioni CSP
  ].join('; ');

  res.setHeader('Content-Security-Policy', cspDirectives);
  next();
});

// Nel template EJS, usa il nonce per gli script inline autorizzati:
// <script nonce="<%= nonce %>">
//   console.log('Script autorizzato dalla CSP con nonce corretto');
// </script>

// Endpoint per ricevere le violazioni CSP dal browser
app.post('/csp-report',
  express.json({ type: 'application/csp-report' }),
  (req, res) => {
    const report = req.body['csp-report'] || req.body;
    // Logga le violazioni per il monitoraggio della sicurezza
    console.warn('[CSP VIOLATION]', {
      blockedUri: report['blocked-uri'],
      violatedDirective: report['violated-directive'],
      documentUri: report['document-uri'],
      ts: new Date().toISOString()
    });
    res.status(204).send();
  }
);

Durante lo sviluppo, usa Content-Security-Policy-Report-Only invece di Content-Security-Policy: la CSP viene valutata e le violazioni vengono segnalate al report-uri ma gli script non vengono bloccati. Questo ti permette di identificare tutte le risorse da aggiungere alla whitelist prima di attivare la CSP in produzione, evitando di rompere funzionalità legittime.

Step 9: Prevenire il DOM-Based XSS nel Frontend

Il DOM-Based XSS è la categoria più insidiosa perché avviene interamente nel browser, senza che il server processi mai il payload. Il codice JavaScript lato client legge dati da fonti non sicure (hash dell’URL, localStorage, postMessage) e li scrive nel DOM tramite metodi pericolosi chiamati “sink”.

Le sorgenti di dati non sicure più comuni nel DOM sono: location.href, location.hash, location.search, document.referrer, window.name, localStorage e i messaggi postMessage. I sink pericolosi principali sono: innerHTML, outerHTML, document.write(), eval() e setTimeout con argomento stringa.

// public/js/safe-dom.js - Gestione sicura del DOM

// PERICOLOSO - Non fare mai questo:
// document.getElementById('output').innerHTML = userInput;
// document.write(userInput);

// SICURO - textContent per testo semplice (non interpreta HTML):
function displayText(elementId, text) {
  const el = document.getElementById(elementId);
  if (!el) return;
  el.textContent = text; // completamente sicuro, nessun HTML interpretato
}

// SICURO - Creazione programmatica di elementi DOM:
function createComment(author, text, timestamp) {
  const article = document.createElement('article');

  const authorEl = document.createElement('strong');
  authorEl.textContent = author; // textContent è sempre sicuro

  const textEl = document.createElement('p');
  textEl.textContent = text;

  const timeEl = document.createElement('time');
  timeEl.textContent = new Date(timestamp).toLocaleDateString('it-IT');
  timeEl.setAttribute('datetime', timestamp); // setAttribute è sicuro

  article.appendChild(authorEl);
  article.appendChild(textEl);
  article.appendChild(timeEl);
  return article;
}

// SICURO - Validazione degli URL prima di usarli come href:
function createSafeLink(url, text) {
  let safeUrl = '#';
  try {
    const parsed = new URL(url);
    if (['https:', 'mailto:'].includes(parsed.protocol)) {
      safeUrl = parsed.href;
    }
  } catch { /* URL non valido: usa fallback '#' */ }

  const link = document.createElement('a');
  link.href = safeUrl;
  link.textContent = text;
  link.rel = 'noopener noreferrer';
  return link;
}

// SICURO - Gestione dei messaggi postMessage con verifica origine:
window.addEventListener('message', (event) => {
  const ALLOWED_ORIGINS = ['https://app.example.it'];
  if (!ALLOWED_ORIGINS.includes(event.origin)) {
    console.warn('postMessage da origine non autorizzata:', event.origin);
    return; // ignora messaggi da origini sconosciute
  }
  if (event.data?.type === 'DISPLAY_TEXT') {
    displayText('content-area', event.data.value);
  }
});

// Se hai assolutamente bisogno di inserire HTML nel DOM,
// usa DOMPurify caricato come modulo con <script src nonce="...">:
// import DOMPurify from 'dompurify';
// element.innerHTML = DOMPurify.sanitize(contentFromServer);

Step 10: Abilitare i Trusted Types per Protezione a Livello di Browser

I Trusted Types sono una API browser moderna (supportata da Chrome 83+, Edge e in sperimentazione su Firefox) che elimina alla radice il DOM-Based XSS rendendo obbligatorio l’uso di valori “fidati” per i sink pericolosi. Se il codice tenta di assegnare una stringa normale a innerHTML, il browser lancia un errore invece di eseguirla. Questa tecnica è raccomandata da Google nel suo hardening interno.

// Aggiunta alla CSP (lato server):
// require-trusted-types-for 'script'; trusted-types dompurify default

// Lato server, aggiungi alla direttiva CSP:
const cspWithTrustedTypes = [
  `default-src 'self'`,
  `script-src 'self' 'nonce-${nonce}'`,
  `require-trusted-types-for 'script'`,        // abilita Trusted Types
  `trusted-types dompurify default`,            // policy autorizzate
  // ... altre direttive
].join('; ');

// public/js/trusted-types-init.js
if (window.trustedTypes && window.trustedTypes.createPolicy) {
  // Crea una policy che usa DOMPurify per sanitizzare HTML
  window.__policy = window.trustedTypes.createPolicy('dompurify', {
    createHTML: (input) => DOMPurify.sanitize(input, {
      RETURN_TRUSTED_TYPE: true
    }),
    createScriptURL: (url) => {
      // Verifica che lo script provenga dalla nostra origine
      const parsed = new URL(url, location.origin);
      if (parsed.origin !== location.origin) {
        throw new Error('URL script non autorizzato');
      }
      return url;
    }
  });

  // Funzione helper per innerHTML sicuro con Trusted Types
  window.safeInnerHTML = function(element, html) {
    element.innerHTML = window.__policy.createHTML(html);
  };
} else {
  // Fallback per browser che non supportano ancora Trusted Types
  window.safeInnerHTML = function(element, html) {
    element.innerHTML = DOMPurify.sanitize(html);
  };
}

// Migrazione del codice esistente:
// Prima: container.innerHTML = serverData;          // PERICOLOSO
// Dopo:  safeInnerHTML(container, serverData);      // SICURO con TT + DOMPurify

Trusted Types richiede un refactoring graduale del codice frontend. Il modo più efficace per adottarli è attivare prima la modalità report-only nella CSP per identificare tutti i sink innerHTML esistenti, correggerli uno per uno, poi passare alla modalità enforcement. Una volta completata la migrazione, è praticamente impossibile introdurre accidentalmente nuovi sink XSS nel codice.

Step 11: Testare la Protezione XSS con Payload Reali

Implementare le difese non è sufficiente: è necessario verificare che funzionino contro payload XSS reali. Il testing va eseguito in un ambiente di staging con gli stessi header e configurazioni della produzione.

# ============================================================
# Test automatico con curl - verifica che i payload XSS vengano bloccati
# ============================================================

echo "=== Verifica header di sicurezza ==="
curl -sI "http://localhost:3000/" | grep -iE "content-security|x-frame|x-content-type|strict-transport"

echo ""
echo "=== Test Reflected XSS ==="
# Payload XSS comuni - nessuno deve essere presente nella risposta senza escaping
PAYLOADS=(
  "%3Cscript%3Ealert(1)%3C/script%3E"
  "%3Cimg+src%3Dx+onerror%3Dalert(1)%3E"
  "%3Csvg+onload%3Dalert(1)%3E"
  "javascript%3Aalert(1)"
  "%27%3E%3Cscript%3Ealert(1)%3C/script%3E"
)

for payload in "${PAYLOADS[@]}"; do
  response=$(curl -s "http://localhost:3000/search?q=$payload")
  if echo "$response" | grep -qiE "<script|onerror=|onload=|javascript:"; then
    echo "VULNERABILE: $payload"
  else
    echo "PROTETTO OK: $(echo $payload | cut -c1-40)..."
  fi
done

echo ""
echo "=== Test validazione input ==="
# Testa che l'input oltre il limite venga rifiutato
curl -s -X POST http://localhost:3000/comment \
  -H "Content-Type: application/json" \
  -d '{"text": "", "author": "Mario"}' | python3 -m json.tool

echo ""
echo "=== Verifica npm audit ==="
npm audit --audit-level=high 2>&1 | tail -5

echo ""
echo "=== Verifica CSP nel browser (usa Chrome DevTools) ==="
echo "Apri DevTools > Network > Headers e verifica:"
echo "  Content-Security-Policy contiene: default-src 'self'"
echo "  X-Frame-Options: SAMEORIGIN"
echo "  X-Content-Type-Options: nosniff"

Per un testing più sistematico, integra OWASP ZAP nella pipeline CI/CD. ZAP include uno scanner XSS automatico che testa centinaia di payload contro tutti i punti di ingresso dell’applicazione. Un’alternativa è usare Burp Suite Community per il testing manuale guidato durante le fasi di sviluppo. Esegui almeno un penetration test manuale all’anno per le applicazioni in produzione.

Step 12: Configurare il Logging per Rilevare Attacchi XSS

La prevenzione è fondamentale, ma il rilevamento è altrettanto importante. Un sistema di logging che registra i tentativi di XSS ti permette di identificare gli aggressori, le tecniche usate e se le difese stanno reggendo nel tempo.

// Middleware di rilevamento e logging dei tentativi XSS
const XSS_PATTERNS = [
  /<script[^>]*>/i,
  /javascript\s*:/i,
  /on\w+\s*=/i,          // onerror=, onload=, onclick=, ecc.
  /<img[^>]+onerror/i,
  /<svg[^>]+onload/i,
  /document\.cookie/i,
  /window\.location/i,
];

function detectXssAttempt(req, res, next) {
  // Controlla tutti i possibili vettori di input
  const valuesToCheck = [
    ...Object.values(req.query),
    ...Object.values(req.params),
    ...(req.body ? Object.values(req.body) : []),
    req.headers['user-agent'] || '',
    req.headers['referer'] || '',
  ].filter(v => typeof v === 'string');

  for (const value of valuesToCheck) {
    for (const pattern of XSS_PATTERNS) {
      if (pattern.test(value)) {
        // Logga il tentativo con tutti i dettagli rilevanti
        console.warn('[XSS ATTEMPT]', JSON.stringify({
          ip: req.ip || req.headers['x-forwarded-for'],
          method: req.method,
          path: req.path,
          pattern: pattern.source,
          value: value.substring(0, 200),
          ts: new Date().toISOString()
        }));
        // In produzione con WAF: blocca la richiesta
        // return res.status(400).json({ error: 'Input non valido' });
        break;
      }
    }
  }
  next();
}

// Applica il middleware prima di tutti i route handler
app.use(detectXssAttempt);

// Endpoint per ricevere violazioni CSP e registrarle
app.post('/csp-report',
  express.json({ type: 'application/csp-report' }),
  (req, res) => {
    const r = req.body['csp-report'] || req.body;
    console.warn('[CSP VIOLATION]', JSON.stringify({
      blockedUri: r['blocked-uri'],
      violatedDirective: r['violated-directive'],
      documentUri: r['document-uri'],
      ts: new Date().toISOString()
    }));
    res.status(204).send();
  }
);

In produzione, centralizza i log su una piattaforma SIEM come Wazuh, Elasticsearch o AWS CloudWatch. I log strutturati in formato JSON si integrano con questi sistemi e permettono di creare alert automatici quando il numero di tentativi XSS supera una soglia configurabile. Questo è particolarmente utile per rilevare attacchi automatizzati e scansioni di vulnerabilità.

Progetto Completo: Server Express.js con Protezione XSS Integrata

Ecco il file server.js completo e funzionante che integra tutte le misure discusse nei 12 step. Puoi usarlo come base per le tue applicazioni Express.js in produzione.

// server.js - Protezione XSS completa per Express.js [2026]
'use strict';

const express   = require('express');
const helmet    = require('helmet');
const sanitize  = require('sanitize-html');
const { body, query, validationResult } = require('express-validator');
const session   = require('express-session');
const crypto    = require('crypto');

const app = express();
const IS_PROD = process.env.NODE_ENV === 'production';

// 1. Nonce crittografico per ogni richiesta
app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64url');
  next();
});

// 2. Helmet con CSP basata su nonce
app.use((req, res, next) => {
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc:  ["'self'"],
        scriptSrc:   ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
        styleSrc:    ["'self'", "'unsafe-inline'"],
        imgSrc:      ["'self'", "data:", "https:"],
        connectSrc:  ["'self'"],
        fontSrc:     ["'self'"],
        objectSrc:   ["'none'"],
        frameSrc:    ["'none'"],
        baseUri:     ["'self'"],
        formAction:  ["'self'"],
        upgradeInsecureRequests: [],
      },
    },
    xXssProtection: false,
  })(req, res, next);
});

// 3. Body parsing con limiti espliciti
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));

// 4. Sessioni sicure
app.use(session({
  secret: process.env.SESSION_SECRET || crypto.randomBytes(64).toString('hex'),
  name:   '__Host-session',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure:   IS_PROD,
    sameSite: 'strict',
    maxAge:   7200000
  }
}));

// 5. Whitelist per sanitizzazione HTML commenti
const COMMENT_OPTS = {
  allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
  allowedAttributes: {},
  allowedSchemes: [],
};

// 6. Gestione validazione centralizzata
const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
  next();
};

// 7. Route: commento sicuro (Stored XSS prevention)
app.post('/api/comments',
  [
    body('text').trim().notEmpty().isLength({ max: 500 }),
    body('author').trim().isAlphanumeric('it-IT', { ignore: ' -' }).isLength({ min: 2, max: 50 }),
  ],
  validate,
  (req, res) => {
    const clean = sanitize(req.body.text, COMMENT_OPTS);
    res.json({ ok: true, text: clean, author: req.body.author });
  }
);

// 8. Route: ricerca sicura (Reflected XSS prevention)
app.get('/api/search',
  [
    query('q').trim().isLength({ max: 200 })
              .customSanitizer(v => v.replace(/[<>"'`]/g, '')),
  ],
  validate,
  (req, res) => res.json({ query: req.query.q, results: [] })
);

// 9. CSP Report endpoint
app.post('/csp-report',
  express.json({ type: 'application/csp-report' }),
  (req, res) => {
    const r = req.body['csp-report'] || req.body;
    console.warn('[CSP]', r['violated-directive'], r['blocked-uri']);
    res.status(204).send();
  }
);

// 10. Health check
app.get('/health', (req, res) => res.json({ status: 'ok', secure: true }));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server XSS-protected attivo sulla porta ${PORT}`));
module.exports = app;

Errori Comuni nella Prevenzione XSS in Node.js

Anche gli sviluppatori esperti commettono errori nella prevenzione XSS. Ecco i 6 errori più frequenti che portano a vulnerabilità in produzione.

Errore 1: Sanitizzare solo l’input, non l’output. La regola fondamentale della sicurezza web è “sanitizza in uscita, valida in ingresso”. Anche se sanitizzi al momento dell’inserimento nel database, devi codificare i dati correttamente al momento della visualizzazione, perché il contesto cambia: HTML, JavaScript, CSS e URL richiedono encoding diversi.

Errore 2: Usare innerHTML con dati dinamici. Questo è il sink XSS più comune nel codice frontend. Anche se i dati provengono dal tuo database, potrebbero essere stati compromessi da un attacco precedente. Usa sempre textContent o DOMPurify prima di assegnare a innerHTML.

Errore 3: Dimenticare i dati provenienti dagli header HTTP. req.headers['referer'], req.headers['user-agent'] e altri header possono contenere payload XSS. Se li mostri in un pannello di amministrazione HTML senza escaping, hai una vulnerabilità Blind XSS che un aggressore può sfruttare per comprometterti l’account amministratore.

Errore 4: Usare ‘unsafe-inline’ nella CSP per comodità. Molti sviluppatori aggiungono 'unsafe-inline' alla CSP per far funzionare rapidamente script inline esistenti, neutralizzando completamente la protezione CSP contro XSS. La soluzione è migrare gli script inline a file separati o usare nonce crittografici.

Errore 5: Non aggiornare le dipendenze di sicurezza. I pacchetti sanitize-html, helmet e express-validator vengono aggiornati regolarmente per correggere bypass XSS scoperti. Imposta Dependabot o Renovate per aggiornamenti automatici delle patch di sicurezza e integra npm audit nella CI/CD.

Errore 6: Trattare i dati JSON come intrinsecamente sicuri. Quando un template HTML incorpora dati JSON direttamente (<script>var d = {"user": "..."}</script>), i caratteri < nei valori possono chiudere il tag </script> e iniettare HTML. Usa sempre JSON.stringify(data).replace(/</g, '\\u003c') quando incorpori JSON in pagine HTML.

Troubleshooting: 8 Problemi Frequenti e le Loro Soluzioni

1. Il payload XSS viene ancora eseguito dopo la sanitizzazione – Causa più probabile: stai usando <%- in EJS (HTML grezzo) invece di <%= (con escaping automatico). Controlla tutti i template e cerca <%-: ogni occorrenza è potenzialmente pericolosa a meno che il contenuto non sia già stato sanitizzato con sanitize-html.

2. Helmet blocca risorse CSS o JavaScript legittimi – La CSP configurata non include i domini di risorse esterne (CDN, Google Fonts). Attiva prima reportOnly: true nella CSP per vedere tutte le violazioni senza bloccare le risorse. Poi aggiungi i domini necessari alle direttive appropriate.

3. La CSP blocca script di analytics o tag manager – Strumenti come Google Analytics iniettano script inline. Soluzione: usa il nonce per autorizzare gli script specifici del tag manager, oppure caricali da file esterni con il dominio nella whitelist scriptSrc.

4. sanitize-html rimuove tag HTML necessari al layout – La configurazione predefinita è molto restrittiva. Aggiungi i tag necessari alla lista allowedTags nella tua configurazione personalizzata. Evita di usare sanitizeHtml.defaults.allowedTags come base perché include molti tag non necessari.

5. I cookie non vengono impostati come HttpOnly in ambiente di staging – La configurazione secure: true richiede HTTPS. Se il server si trova dietro un reverse proxy (Nginx, Cloudflare), aggiungi app.set('trust proxy', 1) prima della configurazione della sessione e verifica che il proxy imposti correttamente l’header X-Forwarded-Proto: https.

6. Gli errori 403 appaiono dopo l’attivazione di Helmet sui form – La direttiva formAction 'self' blocca i form che inviano dati verso domini diversi. Aggiungi i domini di destinazione dei form alla direttiva formAction nella configurazione Helmet.

7. express-validator non blocca un pattern XSS specifico – express-validator valida e sanitizza ma non è progettato come motore di rilevamento XSS. Usa .escape() per l’escaping di base su campi di testo, e sanitize-html per campi che devono accettare HTML. Non cercare di bloccare XSS con regex personalizzate: è quasi impossibile coprire tutti i vettori.

8. Il nonce CSP non funziona con framework SPA (React, Vue, Angular) – I framework SPA generano script durante la build senza conoscere il nonce del server. Soluzione: carica i bundle JavaScript come file statici con <script src> dalla tua origine (già nella whitelist). Usa 'strict-dynamic' nella CSP per consentire agli script fidati di caricare altri script in modo dinamico.

Consigli Avanzati per la Sicurezza XSS in Produzione

Subresource Integrity (SRI): Quando carichi librerie JavaScript da CDN esterni, aggiungi l’attributo integrity al tag <script>. Il browser calcola il checksum SHA-384 del file e lo confronta con quello specificato; se non corrisponde, blocca lo script. Questo protegge contro la compromissione del CDN o attacchi di supply chain. Genera il checksum con: curl -s https://cdn.example.com/lib.js | openssl dgst -sha384 -binary | openssl base64 -A.

Cross-Origin policies: Gli header Cross-Origin-Opener-Policy: same-origin e Cross-Origin-Embedder-Policy: require-corp isolano il processo del browser, riducendo la superficie d’attacco per attacchi XSS combinati con Spectre. Aggiungili alla risposta HTTP quando la tua applicazione non richiede accesso a risorse cross-origin non autorizzate.

Permissions Policy: L’header Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=() disabilita API browser potenzialmente abusabili da payload XSS. Anche se un aggressore esegue codice nella pagina, non può accedere alla geolocalizzazione, alla fotocamera o ai dati di pagamento dell’utente.

WAF come livello aggiuntivo: Soluzioni come Cloudflare WAF o AWS WAF includono regole aggiornate per rilevare payload XSS noti prima che raggiungano l’applicazione. Un WAF non sostituisce la difesa nell’applicazione, ma riduce il rumore di attacchi automatizzati. Il costo di Cloudflare WAF parte da circa 20 euro al mese, significativamente inferiore al costo medio di una violazione dati.

Analisi statica del codice: Integra eslint-plugin-security e semgrep nella pipeline CI/CD per rilevare automaticamente i sink pericolosi (innerHTML, eval, document.write) nel codice JavaScript durante lo sviluppo, prima del commit. Semgrep ha regole specifiche per Node.js XSS disponibili nel suo registro pubblico.

Domande Frequenti sulla Prevenzione XSS in Node.js

Qual è la differenza tra XSS e SQL Injection?
L’XSS inietta codice JavaScript eseguito nel browser della vittima. La SQL Injection inietta codice SQL eseguito nel database del server. Entrambi nascono dalla mancata validazione dell’input, ma hanno vettori e conseguenze diverse. Un’applicazione può essere vulnerabile a entrambi contemporaneamente. Per la prevenzione SQL Injection in Node.js, consulta la nostra guida dedicata.

Helmet.js è sufficiente per prevenire XSS?
No. Helmet imposta gli header HTTP di sicurezza che istruiscono il browser, ma da solo non sanitizza i dati. La protezione completa richiede tre livelli: validazione dell’input (express-validator), sanitizzazione dell’output (sanitize-html, escaping), e policy di sicurezza del browser (Helmet con CSP).

React e Vue proteggono automaticamente dall’XSS?
Parzialmente. React e Vue escapano automaticamente il contenuto reso con JSX e le direttive di binding standard ({variabile} e v-bind). Tuttavia, le funzionalità per HTML grezzo (dangerouslySetInnerHTML in React, v-html in Vue) bypassano questa protezione. Usa sempre DOMPurify con queste funzionalità.

Come prevenire XSS negli editor di testo ricco (TinyMCE, Quill)?
Sanitizza l’HTML lato server con sanitize-html prima del salvataggio e prima della visualizzazione. Non fidarti mai della sanitizzazione inclusa nell’editor: un aggressore può inviare richieste HTTP dirette all’API, bypassando completamente il frontend e il suo editor.

Il WAF sostituisce la protezione XSS nell’applicazione?
No. Un WAF è un complemento utile ma non un sostituto. I WAF possono essere aggirati con tecniche di offuscamento del payload (encoding Unicode, frammentazione, varianti HTML5). La difesa primaria è nell’applicazione: validazione, sanitizzazione e CSP. Il WAF aggiunge uno strato perimetrale contro gli attacchi automatizzati di massa.

Come gestire l’XSS in un’API REST che restituisce solo JSON?
Le API REST con Content-Type: application/json sono meno vulnerabili all’XSS diretto perché il browser non interpreta JSON come HTML. Tuttavia, se il frontend JavaScript usa i dati JSON per costruire HTML tramite innerHTML, la vulnerabilità esiste nell’applicazione client. Sanitizza i dati sia lato server che lato client.

Come testare l’XSS in modo sicuro senza danneggiare l’applicazione?
Usa un ambiente di staging isolato con dati di test. Esegui OWASP ZAP in modalità spider + active scan oppure Burp Suite Community. Per i test manuali, i payload di base come <script>alert(1)</script>, <img src=x onerror=alert(1)> e javascript:alert(1) sono sufficienti per verificare i casi più comuni. Documenta ogni test e il suo risultato.

Ogni quanto aggiornare le librerie di sicurezza?
Le patch di sicurezza vanno applicate entro 72 ore dalla pubblicazione. Per gli aggiornamenti minor e patch regolari, automatizza con Dependabot o Renovate. Per gli aggiornamenti major che possono introdurre breaking changes, testa in staging prima del deploy. Esegui npm audit almeno settimanalmente o ad ogni pull request.

Copertura Correlata

Per approfondire la sicurezza delle applicazioni Node.js:

Risorse Esterne Autorevoli