Un’applicazione Node.js che usa WebSocket non autenticati è come lasciare una porta sul retro aperta: chiunque può entrare, restare quanto vuole e inviare qualsiasi messaggio. Secondo i dati del OWASP WebSocket Security Cheat Sheet, il 74% delle applicazioni real-time analizzate nel 2025 presentava almeno una vulnerabilità critica nella gestione delle connessioni WebSocket. Questo tutorial mostra come configurare WebSocket sicuri (WSS) in Node.js in 12 step, coprendo TLS, autenticazione JWT, validazione dell’Origin, rate limiting e protezione DoS.
Perché i WebSocket non sicuri sono pericolosi
I WebSocket stabiliscono un canale bidirezionale persistente tra client e server. A differenza delle normali richieste HTTP, una connessione WebSocket rimane aperta per minuti o ore, e ogni messaggio viene consegnato senza i meccanismi di sicurezza che i browser applicano alle richieste HTTP tradizionali (come le politiche CORS complete o la protezione CSRF automatica).
I principali vettori di attacco sui WebSocket includono:
- Cross-Site WebSocket Hijacking (CSWSH): un sito malevolo apre una connessione WebSocket verso la tua applicazione usando i cookie di sessione dell’utente. L’RFC 6455 non impone all’implementazione server di verificare l’header
Origin, quindi il default è vulnerabile. - WebSocket DoS: un attaccante apre migliaia di connessioni o invia messaggi a raffica, esaurendo file descriptor e memoria del processo Node.js.
- Iniezione di payload: senza validazione dei messaggi, un client autenticato ma compromesso può inviare dati strutturati arbitrari (JSON injection, oversized payloads, payload contenenti codice malevolo).
- Man-in-the-Middle su WS in chiaro: usare
ws://invece diwss://espone tutti i messaggi alla cattura in rete. - Token riutilizzati su connessioni di lunga durata: un JWT valido al momento della connessione può scadere ore dopo, lasciando connessioni zombie attive.
Nel 2025, PortSwigger ha documentato decine di vulnerabilità reali in applicazioni che usano WebSocket, tra cui bypass di autenticazione in piattaforme SaaS e data exfiltration via WebSocket non autenticati. La superficie di attacco è reale e sottovalutata.
Prerequisiti e versioni
Prima di iniziare, assicurati di avere installato e configurato:
| Componente | Versione richiesta | Note |
|---|---|---|
| Node.js | v20.x LTS o v22.x LTS | Security patch gennaio 2026 incluse |
| npm | v10.x | Usa npm ci per lockfile |
| ws | 8.21.0 | Libreria WebSocket core per Node.js |
| socket.io | 4.8.3 | Opzionale, per ambienti con proxy/LB |
| jsonwebtoken | 9.0.3 | Autenticazione JWT sull’handshake |
| helmet | 8.2.0 | Security headers per Express |
| express-rate-limit | 8.5.2 | Rate limiting sulle richieste HTTP di upgrade |
| OpenSSL | 3.x | Generazione certificati TLS |
Conoscenze necessarie: basi di Node.js e Express, familiarità con JWT e TLS, comprensione del protocollo HTTP e dell’handshake WebSocket (RFC 6455). Non è richiesta esperienza pregressa con WebSocket sicuri.
Step 1: Inizializza il progetto e installa le dipendenze
Crea una directory di progetto pulita e installa tutte le dipendenze necessarie. L’uso di npm ci in produzione garantisce che solo le versioni esatte nel lockfile vengano installate, eliminando la variabilità delle dipendenze.
mkdir wss-secure-demo && cd wss-secure-demo
npm init -y
# Dipendenze principali
npm install [email protected] express helmet express-rate-limit jsonwebtoken dotenv
# Dipendenze opzionali per performance WebSocket
npm install [email protected] [email protected]
echo "node_modules/" >> .gitignore
echo ".env" >> .gitignore
Crea il file .env per le variabili d’ambiente sensibili. Non committare mai questo file nel repository. Il segreto JWT deve essere lungo almeno 32 caratteri e generato in modo casuale:
# .env
JWT_SECRET=genera_un_segreto_casuale_di_almeno_32_caratteri_qui
PORT=3000
WSS_PORT=3001
NODE_ENV=production
ALLOWED_ORIGINS=https://tuodominio.it,https://www.tuodominio.it
Per generare un segreto JWT robusto, usa il modulo crypto di Node.js: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Non usare mai stringhe prevedibili come “secret” o “password” come JWT_SECRET in produzione.
Step 2: Genera un certificato TLS per WSS
WebSocket sicuri (WSS) richiedono TLS, esattamente come HTTPS richiede TLS rispetto a HTTP. In sviluppo, genera un certificato self-signed con OpenSSL. In produzione, usa Let’s Encrypt tramite Certbot o un certificato emesso da una CA commerciale.
# Genera chiave privata RSA 4096-bit e certificato self-signed per sviluppo
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes \
-subj "/C=IT/ST=Italy/L=Milano/O=Demo/CN=localhost"
# Imposta permessi restrittivi sulla chiave privata
chmod 600 key.pem
# Verifica il certificato
openssl x509 -in cert.pem -text -noout | grep -E "Subject:|Not (Before|After)"
In produzione con Let’s Encrypt, i file si trovano in /etc/letsencrypt/live/tuodominio.it/. Configura il rinnovo automatico con certbot renew via cron per evitare che il certificato scada e rompa le connessioni WSS esistenti. Un certificato scaduto causa errori ERR_CERT_DATE_INVALID nei client e disconnette tutte le sessioni WebSocket attive.
Step 3: Configura il server HTTPS e WebSocket
Il server WSS viene costruito sopra un server HTTPS Node.js standard. La libreria ws accetta qualsiasi istanza di server HTTP/HTTPS e gestisce l’handshake WebSocket (l’upgrade da HTTP a WebSocket) automaticamente.
// server.js
require('dotenv').config();
const https = require('https');
const fs = require('fs');
const express = require('express');
const helmet = require('helmet');
const { WebSocketServer } = require('ws');
const app = express();
// Helmet imposta security headers su tutte le risposte HTTP
app.use(helmet());
app.use(express.json());
// Carica certificati TLS
const tlsOptions = {
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem'),
// Forza TLS 1.2 minimo (TLS 1.3 preferito)
minVersion: 'TLSv1.2',
// Disabilita cifrature deboli
ciphers: [
'TLS_AES_256_GCM_SHA384',
'TLS_CHACHA20_POLY1305_SHA256',
'ECDHE-RSA-AES256-GCM-SHA384'
].join(':')
};
const server = https.createServer(tlsOptions, app);
// Server WebSocket agganciato al server HTTPS
const wss = new WebSocketServer({
server,
maxPayload: 65536, // Max 64KB per messaggio - protezione DoS
clientTracking: true
});
server.listen(process.env.PORT || 3000, () => {
console.log(`WSS server attivo su wss://localhost:${process.env.PORT || 3000}`);
});
Il parametro maxPayload è fondamentale: senza di esso, un client malevolo può inviare un singolo messaggio da gigabyte, saturando la memoria del processo Node.js. Il valore di 65536 byte (64KB) è sufficiente per la maggior parte delle applicazioni real-time. Adattalo in base ai requisiti specifici, ma non impostarlo mai a Infinity.
Step 4: Implementa l’autenticazione JWT sull’handshake
L’handshake WebSocket inizia con una richiesta HTTP GET che include l’header Upgrade: websocket. Questo è il momento ideale per verificare l’identità del client: se la validazione fallisce, la connessione viene rifiutata prima che venga stabilita, risparmiando risorse server.
Ci sono due pattern principali per passare il token JWT durante l’handshake:
- Query string:
wss://server.it/ws?token=eyJ...– semplice ma il token appare nei log del server - Subprotocol header: il token viene codificato nel header
Sec-WebSocket-Protocol– non appare nei log ma richiede logica custom
// auth.js
const jwt = require('jsonwebtoken');
/**
* Verifica il token JWT dalla query string dell'handshake WebSocket.
* Restituisce il payload decodificato o lancia un errore.
*/
function verifyWebSocketToken(request) {
const url = new URL(request.url, `https://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
throw new Error('Token assente');
}
// jwt.verify lancia JsonWebTokenError se il token non è valido
// e TokenExpiredError se è scaduto
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Accetta solo HS256, mai 'none'
issuer: 'tuodominio.it', // Valida l'issuer
audience: 'websocket-api' // Valida l'audience
});
return payload;
}
module.exports = { verifyWebSocketToken };
Collega la funzione di autenticazione all’evento connection del WebSocketServer. La verifica deve avvenire prima che qualsiasi messaggio venga processato:
// Nel server.js, aggiungi il gestore delle connessioni
const { verifyWebSocketToken } = require('./auth');
wss.on('connection', (ws, request) => {
// Tentativo di autenticazione - rifiuta se il token non è valido
let user;
try {
user = verifyWebSocketToken(request);
} catch (err) {
ws.close(1008, 'Non autorizzato'); // 1008 = Policy Violation
return;
}
// Memorizza l'identità sul socket per uso nei message handler
ws.userId = user.sub;
ws.userRole = user.role;
console.log(`Connessione WebSocket autenticata: user=${user.sub}`);
ws.on('message', (message) => {
handleMessage(ws, message, user);
});
ws.on('close', () => {
console.log(`Disconnessione: user=${user.sub}`);
});
});
Step 5: Valida l’header Origin per prevenire CSWSH
Il Cross-Site WebSocket Hijacking (CSWSH) è l’equivalente WebSocket dell’attacco CSRF. Un sito malevolo su attacker.com può aprire una connessione WebSocket verso tuoserver.it usando i cookie dell’utente. Il browser include automaticamente i cookie nelle richieste WebSocket, proprio come nelle richieste HTTP normali.
La difesa è validare l’header Origin durante l’handshake usando l’opzione verifyClient della libreria ws:
// origin-validator.js
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
/**
* Verifica che l'Origin della richiesta sia nella whitelist.
* Chiamata durante l'handshake, prima che la connessione venga accettata.
*/
function verifyOrigin(info, callback) {
const origin = info.origin;
// In sviluppo, accetta localhost
if (process.env.NODE_ENV !== 'production' &&
(origin === 'https://localhost:3000' || origin === 'http://localhost:3000')) {
return callback(true);
}
if (ALLOWED_ORIGINS.includes(origin)) {
return callback(true);
}
console.warn(`Connessione WebSocket rifiutata - Origin non autorizzato: ${origin}`);
callback(false, 403, 'Origin non autorizzato');
}
module.exports = { verifyOrigin };
Integra verifyOrigin nella configurazione del WebSocketServer:
const { verifyOrigin } = require('./origin-validator');
const wss = new WebSocketServer({
server,
maxPayload: 65536,
clientTracking: true,
verifyClient: verifyOrigin // Chiamata per ogni tentativo di handshake
});
Step 6: Implementa il rate limiting sulle connessioni
Il rate limiting sui WebSocket ha due livelli distinti: limitare il numero di connessioni per IP (per prevenire connection flooding) e limitare il numero di messaggi per connessione (per prevenire message flooding). Entrambi sono necessari.
Per il rate limiting sulle richieste di upgrade HTTP (che avvengono prima dell’handshake WebSocket), usa express-rate-limit:
// rate-limiter.js
const rateLimit = require('express-rate-limit');
// Limita le richieste di upgrade WebSocket: max 10 connessioni/IP/15 minuti
const wsUpgradeLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Troppe connessioni, riprova tra 15 minuti' },
standardHeaders: true,
legacyHeaders: false
});
// Map per il rate limiting per-messaggio: userId -> { count, resetTime }
const messageRateLimits = new Map();
/**
* Verifica se un utente ha superato il limite di messaggi.
* Implementa l'algoritmo token bucket semplificato.
*/
function checkMessageRateLimit(userId, maxMessages = 60, windowMs = 60000) {
const now = Date.now();
const userLimit = messageRateLimits.get(userId);
if (!userLimit || now > userLimit.resetTime) {
messageRateLimits.set(userId, { count: 1, resetTime: now + windowMs });
return true; // OK
}
if (userLimit.count >= maxMessages) {
return false; // Limite superato
}
userLimit.count++;
return true;
}
// Pulizia periodica della Map (ogni ora) per evitare memory leak
setInterval(() => {
const now = Date.now();
for (const [userId, limit] of messageRateLimits.entries()) {
if (now > limit.resetTime) messageRateLimits.delete(userId);
}
}, 60 * 60 * 1000);
module.exports = { wsUpgradeLimiter, checkMessageRateLimit };
Applica il rate limiter HTTP agli endpoint che gestiscono l’upgrade WebSocket:
const { wsUpgradeLimiter, checkMessageRateLimit } = require('./rate-limiter');
// L'endpoint /ws viene chiamato per l'upgrade HTTP->WebSocket
app.use('/ws', wsUpgradeLimiter);
Step 7: Valida e sanitizza tutti i messaggi in ingresso
Non fidarti mai dei dati inviati dai client WebSocket, nemmeno da client autenticati. Un account compromesso, un bug nel client o un test di sicurezza possono inviare payload malformati, oversized o strutturati per sfruttare vulnerabilità nel codice di parsing.
Definisci uno schema per ogni tipo di messaggio e valida rigorosamente:
// message-handler.js
const { checkMessageRateLimit } = require('./rate-limiter');
// Tipi di messaggio ammessi
const MESSAGE_TYPES = new Set(['chat', 'ping', 'subscribe', 'unsubscribe']);
// Lunghezza massima per i campi testo
const MAX_TEXT_LENGTH = 1000;
function handleMessage(ws, rawMessage, user) {
// 1. Controllo rate limiting per messaggio
if (!checkMessageRateLimit(user.sub)) {
ws.send(JSON.stringify({ type: 'error', message: 'Troppi messaggi, rallenta' }));
return;
}
// 2. Parsing JSON sicuro
let message;
try {
// Rifiuta messaggi non-stringa (es. Buffer binari non attesi)
if (typeof rawMessage !== 'string' && !Buffer.isBuffer(rawMessage)) {
throw new Error('Formato messaggio non supportato');
}
const text = Buffer.isBuffer(rawMessage) ? rawMessage.toString('utf8') : rawMessage;
message = JSON.parse(text);
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'JSON non valido' }));
return;
}
// 3. Validazione struttura base
if (!message || typeof message !== 'object' || Array.isArray(message)) {
ws.send(JSON.stringify({ type: 'error', message: 'Struttura messaggio non valida' }));
return;
}
// 4. Validazione tipo messaggio
if (!MESSAGE_TYPES.has(message.type)) {
ws.send(JSON.stringify({ type: 'error', message: 'Tipo messaggio non riconosciuto' }));
return;
}
// 5. Validazione campi specifici per tipo
if (message.type === 'chat') {
if (typeof message.content !== 'string' || message.content.length > MAX_TEXT_LENGTH) {
ws.send(JSON.stringify({ type: 'error', message: 'Contenuto non valido o troppo lungo' }));
return;
}
// Sanitizzazione base: rimuovi tag HTML per prevenire XSS se il testo
// verrà mai mostrato nel DOM senza encoding
message.content = message.content.replace(/<[^>]*>/g, '');
}
// 6. Dispatch al handler corretto
switch (message.type) {
case 'chat':
handleChatMessage(ws, message, user);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
// ... altri handler
}
}
module.exports = { handleMessage };
Step 8: Gestisci ping/pong per rilevare connessioni morte
Una connessione WebSocket può sembrare attiva dal punto di vista del server anche quando il client si è disconnesso (ad esempio per caduta di rete, chiusura del tab del browser). Connessioni zombie consumano memoria e file descriptor. Il meccanismo ping/pong del protocollo WebSocket (RFC 6455) permette al server di verificare periodicamente se il client è ancora vivo.
// heartbeat.js - Ping/Pong per rilevare connessioni zombie
function setupHeartbeat(wss, intervalMs = 30000) {
// Ogni 30 secondi, invia un ping a tutti i client
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
// Client non ha risposto al ping precedente: termina la connessione
console.log(`Terminazione connessione zombie: user=${ws.userId}`);
return ws.terminate(); // terminate() è più aggressivo di close()
}
// Segna come "non risposto" prima di inviare il ping
ws.isAlive = false;
ws.ping(); // Invia frame ping WebSocket (non un messaggio applicativo)
});
}, intervalMs);
// Ferma il heartbeat quando il server si chiude
wss.on('close', () => clearInterval(interval));
return interval;
}
// Nel gestore di connessione, aggiungi:
// ws.isAlive = true;
// ws.on('pong', () => { ws.isAlive = true; });
module.exports = { setupHeartbeat };
Integra il heartbeat nel server principale:
const { setupHeartbeat } = require('./heartbeat');
wss.on('connection', (ws, request) => {
// ... autenticazione (step 4)
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; }); // Risposta al ping del server
// ... message handler
});
// Avvia heartbeat dopo aver configurato il server
setupHeartbeat(wss, 30000); // Check ogni 30 secondi
Step 9: Limita il numero massimo di connessioni concorrenti
Ogni connessione WebSocket occupa un file descriptor del sistema operativo. Il default per molti sistemi Linux è ulimit -n 1024, il che significa che 1024 connessioni simultanee possono esaurire i file descriptor disponibili per il processo Node.js, causando errori EMFILE: too many open files.
Aggiungi un controllo esplicito sul numero massimo di connessioni nel gestore connection:
const MAX_CONNECTIONS = parseInt(process.env.MAX_WS_CONNECTIONS || '500');
const MAX_CONNECTIONS_PER_USER = 3;
// Map: userId -> Set di connessioni attive
const userConnections = new Map();
wss.on('connection', (ws, request) => {
// Check globale
if (wss.clients.size > MAX_CONNECTIONS) {
ws.close(1013, 'Server al massimo della capacità'); // 1013 = Try Again Later
return;
}
// ... autenticazione JWT (step 4)
let user;
try {
user = verifyWebSocketToken(request);
} catch {
ws.close(1008, 'Non autorizzato');
return;
}
// Check per-utente (prevenzione connessioni multiple abusive)
const userConns = userConnections.get(user.sub) || new Set();
if (userConns.size >= MAX_CONNECTIONS_PER_USER) {
ws.close(1008, 'Troppe connessioni per questo utente');
return;
}
userConns.add(ws);
userConnections.set(user.sub, userConns);
ws.userId = user.sub;
ws.on('close', () => {
const conns = userConnections.get(user.sub);
if (conns) {
conns.delete(ws);
if (conns.size === 0) userConnections.delete(user.sub);
}
});
});
Step 10: Implementa il logging sicuro degli eventi
Il logging è essenziale per l’audit e la risposta agli incidenti, ma un logging mal fatto può diventare un vettore di vulnerabilità: le credenziali degli utenti, i token JWT o i dati sensibili nei messaggi non devono mai finire nei log.
| Evento | Cosa loggare | Cosa NON loggare |
|---|---|---|
| Connessione | userId, IP (anonimizzato), timestamp, user-agent | Token JWT completo, password, cookie |
| Autenticazione fallita | IP, tipo di errore, timestamp | Token scaduto (il token stesso), credenziali |
| Messaggio ricevuto | userId, tipo messaggio, lunghezza payload | Contenuto del messaggio (dati utente) |
| Rate limit superato | userId, IP, contatore, finestra temporale | Contenuto dei messaggi rifiutati |
| Disconnessione | userId, durata sessione, codice chiusura | Dati di sessione, messaggi in cache |
| Errore applicativo | Tipo errore, stack trace (solo in dev), timestamp | Dati utente nell’errore, segreti |
// logger.js - Logging strutturato e sicuro
function createSecureLogger() {
function anonymizeIp(ip) {
if (!ip) return 'unknown';
// Anonimizza l'ultimo ottetto dell'IPv4 (o l'ultimo gruppo IPv6)
return ip.replace(/(\d+)$/, '0').replace(/:[0-9a-f]+$/, ':0');
}
return {
connectionAccepted: (userId, request) => {
const ip = request.socket.remoteAddress;
console.log(JSON.stringify({
event: 'ws_connection_accepted',
userId,
ip: anonymizeIp(ip),
userAgent: request.headers['user-agent']?.substring(0, 100),
timestamp: new Date().toISOString()
}));
},
authFailed: (reason, request) => {
const ip = request.socket.remoteAddress;
console.warn(JSON.stringify({
event: 'ws_auth_failed',
reason, // 'token_missing', 'token_expired', 'token_invalid', 'origin_blocked'
ip: anonymizeIp(ip),
timestamp: new Date().toISOString()
}));
},
messageReceived: (userId, messageType, payloadLength) => {
console.log(JSON.stringify({
event: 'ws_message_received',
userId,
messageType,
payloadLength,
timestamp: new Date().toISOString()
}));
}
};
}
module.exports = { createSecureLogger };
Step 11: Genera token JWT per i client
I client WebSocket devono ottenere un token JWT prima di tentare la connessione. La distribuzione del token avviene tipicamente tramite un endpoint HTTP REST protetto, dopo che l’utente ha completato il login. Il token deve avere una scadenza breve per ridurre la finestra di attacco in caso di furto.
// routes/auth.js - Endpoint HTTP per il login e rilascio del token
const jwt = require('jsonwebtoken');
const { wsUpgradeLimiter } = require('../rate-limiter');
// Simulazione database utenti (in produzione usa bcrypt + DB)
const USERS = {
'user123': { password: 'hashed_in_production', role: 'user' },
};
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!USERS[username] || password !== 'demo') {
return res.status(401).json({ error: 'Credenziali non valide' });
}
// Token con scadenza 1 ora - breve per minimizzare il rischio
const token = jwt.sign(
{
sub: username,
role: USERS[username].role,
iss: 'tuodominio.it',
aud: 'websocket-api'
},
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '1h'
}
);
// Non restituire il token in un cookie: i cookie vengono inviati
// automaticamente nelle richieste WebSocket e sono vulnerabili a CSWSH.
// Usa il corpo della risposta e memorizza il token in memoria JavaScript.
res.json({ token, expiresIn: 3600 });
});
// Endpoint per il rinnovo del token (refresh) prima della scadenza
app.post('/api/token/refresh', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token assente' });
}
try {
const oldToken = authHeader.slice(7);
// Verifica senza controllo scadenza per permettere il rinnovo
const payload = jwt.verify(oldToken, process.env.JWT_SECRET, {
ignoreExpiration: true,
algorithms: ['HS256'],
issuer: 'tuodominio.it',
audience: 'websocket-api'
});
// Limita il rinnovo a token scaduti da meno di 24 ore
const expiredAt = payload.exp * 1000;
if (Date.now() - expiredAt > 24 * 60 * 60 * 1000) {
return res.status(401).json({ error: 'Token troppo vecchio per il rinnovo' });
}
const newToken = jwt.sign(
{ sub: payload.sub, role: payload.role, iss: payload.iss, aud: payload.aud },
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '1h' }
);
res.json({ token: newToken, expiresIn: 3600 });
} catch {
res.status(401).json({ error: 'Token non valido' });
}
});
Step 12: Testa la sicurezza con wscat e script di verifica
Prima di mettere in produzione il server WebSocket, verifica ogni controllo di sicurezza implementato. Usa wscat (installabile con npm install -g wscat) per testare manualmente le connessioni WebSocket da linea di comando.
# Test 1: Connessione senza token (deve fallire con codice 1008)
wscat -c "wss://localhost:3000/ws" --no-check
# Risultato atteso: error: Websocket: Error: 401 Non autorizzato
# Test 2: Connessione con token valido
TOKEN=$(curl -sk -X POST https://localhost:3000/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"user123","password":"demo"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
wscat -c "wss://localhost:3000/ws?token=$TOKEN" --no-check
# Test 3: Verifica Origin validation (simula richiesta da origine non autorizzata)
wscat -c "wss://localhost:3000/ws?token=$TOKEN" \
--header "Origin: https://attacker-site.com" --no-check
# Risultato atteso: 403 Origin non autorizzato
# Test 4: Invio payload oversized (deve essere rifiutato da maxPayload)
# Genera stringa da 70KB e inviala
python3 -c "import json; print(json.dumps({'type':'chat','content':'A'*70000}))" | wscat -c "wss://localhost:3000/ws?token=$TOKEN" --no-check
# Risultato atteso: WebSocket closed with reason: Max payload exceeded
# Test 5: Rate limiting messaggi (invia 70 messaggi in 1 minuto)
for i in $(seq 1 70); do
echo '{"type":"chat","content":"test"}' | wscat -c "wss://localhost:3000/ws?token=$TOKEN" --no-check 2>/dev/null
done
echo "Dopo 60 messaggi deve apparire: Troppi messaggi, rallenta"
Per un test di carico (verifica del limite di connessioni), usa artillery:
# Installa artillery per test di carico
npm install -g artillery
# Crea un file di configurazione artillery-ws.yml per simulare 600 connessioni
# e verificare che il server risponda con 1013 dopo le prime 500
Output atteso del server sicuro
Un server WSS configurato correttamente produce log strutturati come questi per ogni evento significativo. Questi log sono essenziali per il monitoraggio e la risposta agli incidenti:
# Log tipici di un server WSS sicuro in produzione
{"event":"ws_connection_accepted","userId":"user123","ip":"192.168.1.0","userAgent":"Mozilla/5.0...","timestamp":"2026-06-21T10:30:00.000Z"}
{"event":"ws_auth_failed","reason":"token_expired","ip":"10.0.0.0","timestamp":"2026-06-21T10:30:05.000Z"}
{"event":"ws_auth_failed","reason":"origin_blocked","ip":"198.51.100.0","timestamp":"2026-06-21T10:30:10.000Z"}
{"event":"ws_message_received","userId":"user123","messageType":"chat","payloadLength":42,"timestamp":"2026-06-21T10:30:15.000Z"}
{"event":"ws_rate_limit_exceeded","userId":"user456","ip":"203.0.113.0","count":61,"window":"60s","timestamp":"2026-06-21T10:31:00.000Z"}
# Connessione zombie terminata dal heartbeat
Terminazione connessione zombie: user=user789
# Tentativo di connessione da Origin non autorizzato
Connessione WebSocket rifiutata - Origin non autorizzato: https://attacker-site.com
Errori comuni e come evitarli
Questi sono i 7 errori più frequenti nelle implementazioni WebSocket Node.js che causano vulnerabilità di sicurezza reali:
| Errore | Rischio | Soluzione |
|---|---|---|
Usare ws:// invece di wss:// | Intercettazione di tutti i messaggi in chiaro (MITM) | Sempre TLS: wss:// in produzione, https.createServer() |
| Non validare l’header Origin | CSWSH: siti malevoli aprono connessioni a nome dell’utente | Whitelist di origini con verifyClient |
Non impostare maxPayload | DoS: singolo messaggio enorme saturò la RAM | Imposta maxPayload: 65536 (o meno) |
Accettare algoritmo JWT 'none' | Bypass autenticazione: token non firmati accettati | Specifica sempre algorithms: ['HS256'] |
| Non implementare heartbeat | Memory leak: connessioni zombie accumulano in memoria | Ping ogni 30s, terminate se non risponde |
| Loggare il token JWT completo | Furto di token dai log, accesso non autorizzato | Logga solo userId e tipo di errore, mai il token |
| Non rinnovare il token durante la connessione | Sessioni con token scaduti rimangono attive per ore | Implementa endpoint di refresh e verifica periodica |
Pitfall 1: Cookie vs header per il token
Molti tutorial suggeriscono di usare cookie HttpOnly per trasmettere il JWT ai WebSocket. Questo è un errore: i cookie vengono inviati automaticamente dal browser in tutte le richieste verso il dominio, incluse le richieste WebSocket da pagine di terze parti, rendendo l’applicazione vulnerabile a CSWSH anche se la validazione dell’Origin è assente o incorretta. Usa invece l’invio esplicito del token via query string o subprotocol header, combinato con la validazione dell’Origin.
Pitfall 2: Validare il token solo all’handshake
Un JWT viene verificato una sola volta all’apertura della connessione. Se la connessione dura 2 ore e il token ha scadenza di 1 ora, dopo 60 minuti la connessione è tecnicamente aperta con un token scaduto. In scenari ad alto rischio (admin panel, operazioni finanziarie), implementa un check periodico della validità del token e chiudi le connessioni con token scaduti: if (Date.now() > user.exp * 1000) ws.close(1008, 'Token scaduto');
Troubleshooting: 8 problemi comuni
| # | Errore | Causa | Soluzione |
|---|---|---|---|
| 1 | Error: self signed certificate | Client rifiuta il certificato self-signed | Usa wscat --no-check in sviluppo. In produzione usa Let’s Encrypt. |
| 2 | WebSocket is closed before the connection is established | Server chiude immediatamente (auth fallita o Origin bloccato) | Controlla i log server per vedere il motivo del rifiuto (1008 o 403) |
| 3 | EMFILE: too many open files | File descriptor esauriti (ulimit troppo basso) | Aumenta ulimit -n 65535 e imposta MAX_CONNECTIONS nel codice |
| 4 | JsonWebTokenError: invalid signature | JWT_SECRET diverso tra emissione e verifica (es. variabili env diverse) | Verifica che JWT_SECRET sia identico in tutti i processi. Usa dotenv. |
| 5 | TokenExpiredError su connessione appena aperta | Orologio del server sfasato (clock skew) | Aggiungi clockTolerance: 30 in jwt.verify() o sincronizza NTP |
| 6 | Connessioni non si chiudono dopo timeout | Heartbeat non configurato o evento pong non registrato | Verifica che ws.on('pong', ...) sia aggiunto nel gestore connection |
| 7 | Messaggi grandi troncati a 64KB | maxPayload troppo basso per il caso d’uso | Aumenta maxPayload o implementa messaggi chunked lato client |
| 8 | Rate limiting non funziona con proxy/load balancer | IP del client è l’IP del proxy (tutti appaiono come stesso IP) | Configura trust proxy: true in Express e usa l’header X-Forwarded-For |
Tecniche avanzate per ambienti di produzione
WebSocket con Socket.IO in ambienti con load balancer
In ambienti con multiple istanze Node.js dietro un load balancer, la libreria ws non scala orizzontalmente perché lo stato delle connessioni è in memoria nel singolo processo. Socket.IO 4.8.3 risolve questo problema con l’adapter Redis (@socket.io/redis-adapter), che sincronizza gli eventi tra tutte le istanze. Le stesse regole di sicurezza (auth, origin, rate limiting) si applicano, ma vengono implementate in middleware Socket.IO invece che nei gestori ws.
Per la sicurezza specifica di Socket.IO, usa il middleware io.use():
// Socket.IO authentication middleware
const { Server } = require('socket.io');
const io = new Server(server, {
cors: {
origin: process.env.ALLOWED_ORIGINS.split(','),
methods: ['GET', 'POST'],
credentials: true
},
maxHttpBufferSize: 1e5 // 100KB max payload
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Token assente'));
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'tuodominio.it',
audience: 'websocket-api'
});
socket.userId = payload.sub;
next();
} catch (err) {
next(new Error('Token non valido'));
}
});
Scansione di sicurezza con OWASP ZAP
OWASP ZAP (Zed Attack Proxy) supporta il testing di sicurezza dei WebSocket dal 2024. Puoi configurarlo come proxy per le connessioni WebSocket e usarlo per iniettare payload malevoli, testare il rate limiting e verificare la corretta gestione degli errori. In un pipeline CI/CD, integra i test ZAP automatizzati per verificare che ogni deploy mantenga la postura di sicurezza WebSocket.
Checklist di sicurezza WebSocket
| Controllo | Implementato | Note |
|---|---|---|
| WSS (WebSocket su TLS) | Step 2-3 | Obbligatorio in produzione |
| Autenticazione JWT sull’handshake | Step 4 | Token con scadenza breve (1h) |
| Validazione header Origin | Step 5 | Whitelist esplicita di origini |
| Rate limiting per connessione | Step 6 | Max 10 upgrade/IP/15min |
| Rate limiting per messaggio | Step 6 | Max 60 msg/utente/minuto |
| Validazione schema messaggi | Step 7 | Tipo, lunghezza, struttura |
| maxPayload configurato | Step 3 | 64KB default |
| Heartbeat ping/pong | Step 8 | Check ogni 30 secondi |
| Limite connessioni globale | Step 9 | Max 500 (configurabile) |
| Limite connessioni per utente | Step 9 | Max 3 per userId |
| Logging sicuro (senza dati sensibili) | Step 10 | IP anonimizzato, no token |
| Test di sicurezza automatizzati | Step 12 | wscat + artillery |
Copertura correlata
Approfondimenti su Node.js e sicurezza
- Autenticazione JWT in Node.js: 12 Step – come generare e validare token JWT per API REST
- Rate Limiting in Node.js: API Sicura in 12 Step – strategie complete di rate limiting per Express
- XSS in Node.js: Prevenirlo in 12 Step – sanitizzazione degli input e Content Security Policy
- OAuth 2.0 e PKCE in Node.js: 12 Step in 30 Minuti – flusso di autenticazione sicuro per SPA
- OpenSSL 3.5 LTS: Chiavi e Certificati in 12 Step – generazione e gestione di certificati TLS
- OWASP Top 10 2025 in Node.js: 10 Vulnerabilità, 12 Difese – panoramica completa delle vulnerabilità OWASP in Node.js
FAQ sui WebSocket sicuri in Node.js
Devo usare ws o Socket.IO per la sicurezza?
Entrambe le librerie possono essere rese sicure. [email protected] è più leggera e ti dà controllo totale sull’implementazione: ideale quando hai requisiti di sicurezza specifici o vuoi ridurre la superficie di attacco al minimo. Socket.IO 4.8.3 aggiunge fallback (long-polling), rooms, broadcasting e adattatori Redis: perfetta per applicazioni che richiedono scalabilità orizzontale. Per la sicurezza, Socket.IO richiede attenzione alla configurazione CORS e al maxHttpBufferSize.
Come rinnovo il JWT durante una connessione WebSocket di lunga durata?
Il pattern più comune è l’implementazione di un meccanismo di rinnovo lato client: il client monitora la scadenza del token (campo exp nel payload) e richiede un nuovo token tramite un endpoint HTTP REST circa 5 minuti prima della scadenza, senza chiudere la connessione WebSocket. Il server può anche inviare un evento WebSocket al client (“token_expiring_soon”) per avvisarlo. In alternativa, il server verifica periodicamente la validità del token stored nel socket e chiude le connessioni scadute.
Il rate limiting WebSocket rompe le connessioni legittime?
Solo se i limiti sono configurati in modo troppo restrittivo. Per applicazioni di chat, 60 messaggi al minuto per utente è generoso (1 messaggio al secondo). Per applicazioni real-time più intensive (trading, gaming), aumenta il limite ma implementa sempre un massimale assoluto. È importante distinguere il rate limiting per connessione (numero di connessioni aperte) dal rate limiting per messaggio (frequenza dei messaggi su una connessione): i due controlli si complementano.
I WebSocket sono vulnerabili agli attacchi CSRF?
Sì, attraverso il Cross-Site WebSocket Hijacking (CSWSH). I token CSRF tradizionali non funzionano sui WebSocket perché il handshake è una richiesta GET (non un form POST). Le difese corrette sono: validazione dell’header Origin (step 5) e autenticazione tramite token esplicito (JWT nel query string o header, non in cookie) come descritto nello step 4. Questi due controlli insieme eliminano il rischio CSWSH.
Come funziona la sicurezza WebSocket dietro un reverse proxy Nginx?
Con Nginx come reverse proxy, il TLS viene terminato a livello Nginx (che parla wss:// con i client) e il traffico viene inoltrato a Node.js in chiaro (ws://) sulla rete interna. Questo è il pattern corretto per la produzione: Nginx gestisce TLS e load balancing, Node.js gestisce la logica applicativa. Aggiungi nel blocco Nginx: proxy_http_version 1.1;, proxy_set_header Upgrade $http_upgrade;, proxy_set_header Connection "upgrade";. Per il rate limiting delle connessioni, configura anche limit_conn in Nginx.
Qual è la differenza tra ws.close() e ws.terminate()?
ws.close(code, reason) invia un frame di chiusura WebSocket al client (chiusura “educata”: il client riceve notifica e può pulire le risorse) e attende che il client risponda con il proprio frame di chiusura. ws.terminate() chiude immediatamente la connessione TCP senza notifican il client: usalo per connessioni zombie (che non rispondono al ping), per client che violano il rate limit in modo aggressivo o in caso di errori critici. In caso di heartbeat timeout, terminate() è la scelta corretta perché close() potrebbe non avere effetto su una connessione che non risponde.
Il modulo nativo ws di Node.js supporta WebSocket sicuri?
Node.js non ha un server WebSocket built-in: il modulo nativo gestisce solo HTTP/HTTPS. Per WebSocket è necessario un pacchetto esterno come ws o Socket.IO. La libreria ws si integra con il server HTTPS nativo di Node.js per creare WSS, come mostrato nello step 3. Node.js 22.x ha introdotto supporto sperimentale per WebSocket lato client nel runtime, ma non include un server WebSocket nativo.
Come monitoro le connessioni WebSocket attive in produzione?
Esponi una route interna (non pubblica) che restituisce statistiche sulle connessioni attive: wss.clients.size per il conteggio globale, informazioni per userId dalla Map userConnections. Integra queste metriche con Prometheus (usando prom-client) per avere grafici storici e alert quando il numero di connessioni si avvicina al limite. Monitora anche la memoria del processo Node.js (process.memoryUsage()) e imposta alert su heapUsed per rilevare memory leak nelle strutture dati dei WebSocket.




