Un Web Application Firewall (WAF) con ModSecurity e NGINX è il modo più robusto per proteggere un’applicazione Node.js dagli attacchi più comuni: SQL injection, cross-site scripting, path traversal e centinaia di exploit noti. In questa guida passo dopo passo imparerai a installare ModSecurity 3.x su Ubuntu 24.04, integrarlo con NGINX come reverse proxy e applicare le regole OWASP Core Rule Set (CRS) alla tua app Express. Tempo stimato: 30 minuti su un server pulito.
Il benchmark 2026 sui WAF enterprise ha misurato un True Positive Rate del 99,56% per la configurazione ModSecurity con profilo critico, con un False Positive Rate dello 0,563%. Cloud provider come AWS WAF e Azure WAF raggiungono il 97,5%, ma a costi mensili significativi. ModSecurity, open source e gratuito, consente lo stesso livello di protezione su infrastruttura propria.
Prerequisiti e versioni
Prima di iniziare, assicurati di avere:
- Ubuntu 24.04 LTS (Noble Numbat) con accesso root o sudo
- Node.js 22.x LTS con un’applicazione Express in ascolto sulla porta 3000
- NGINX 1.24.x o versione compatibile (per il connettore dinamico)
- RAM minima 2 GB (la compilazione di ModSecurity richiede risorse)
- Disco libero 4 GB per i sorgenti, i moduli e i log
- Git 2.x installato
- Un certificato SSL/TLS attivo (usa Let’s Encrypt e Certbot per ottenerlo gratuitamente)
Il tutorial copre l’installazione da sorgente. Sebbene esistano pacchetti precompilati, la versione da sorgente garantisce la compatibilità ABI con la versione NGINX in uso e consente di personalizzare le opzioni di compilazione.
Architettura: WAF davanti a Node.js
Il flusso delle richieste HTTP/HTTPS nella configurazione che costruiamo è il seguente:
Client → NGINX (porta 443, SSL) → ModSecurity + OWASP CRS (ispezione Layer 7) → Node.js/Express (porta 3000)
NGINX funge da reverse proxy TLS-terminante. Prima di inoltare ogni richiesta all’app Node.js, il modulo dinamico ModSecurity intercetta il traffico, applica le regole CRS e blocca tutto ciò che corrisponde a un pattern malevolo. Se una richiesta viene bloccata, NGINX restituisce un HTTP 403 al client senza che Node.js riceva mai i dati. Questo approccio offre tre vantaggi: la logica applicativa rimane separata dalla logica di sicurezza, il WAF può essere aggiornato senza modificare il codice dell’app, e lo stack di protezione è riutilizzabile su qualsiasi backend (PHP, Python, Go).
| Componente | Ruolo | Versione 2026 |
|---|---|---|
| ModSecurity | Engine WAF open source | v3/master (branch stabile) |
| ModSecurity-nginx | Connettore modulo dinamico | Compatibile con NGINX 1.24.x |
| OWASP CRS | Set di regole per OWASP Top 10 | Ultima versione dal repo GitHub |
| NGINX | Reverse proxy + TLS termination | 1.24.0 |
| Node.js | Backend applicativo | 22.x LTS |
Step 1: Installare le dipendenze di compilazione
ModSecurity 3.x non è disponibile come pacchetto .deb diretto per Ubuntu 24.04 con il connettore NGINX. È necessario compilare dalla sorgente. Aggiorna i repository e installa tutte le dipendenze in un’unica operazione:
sudo apt update && sudo apt upgrade -y
sudo apt install -y \
git build-essential autoconf automake libtool pkg-config \
libpcre2-dev libxml2-dev libyajl-dev liblmdb-dev \
libcurl4-openssl-dev libssl-dev doxygen \
libgeoip-dev libmaxminddb-dev liblua5.3-dev \
libnghttp2-dev libgnutls28-dev zlib1g-dev \
wget curl
Il pacchetto libpcre2-dev è richiesto per il pattern matching delle regole CRS. libyajl-dev abilita il parsing JSON per l’ispezione dei corpi delle richieste REST/API. libmaxminddb-dev aggiunge il supporto GeoIP2 per bloccare o limitare il traffico per paese.
Verifica che tutti i pacchetti siano stati installati correttamente:
dpkg -l | grep -E 'libpcre2|libxml2|libyajl' | awk '{print $2, $3}'
Step 2: Compilare ModSecurity 3.x
Posizionati nella directory dei sorgenti e clona il repository ufficiale di ModSecurity dal branch stabile v3:
cd /usr/local/src
sudo git clone --depth 1 -b v3/master --single-branch \
https://github.com/SpiderLabs/ModSecurity
cd ModSecurity
sudo git submodule init
sudo git submodule update
sudo ./build.sh
sudo ./configure
sudo make -j"$(nproc)"
sudo make install
La compilazione richiede da 5 a 15 minuti a seconda della CPU disponibile. L’opzione -j"$(nproc)" usa tutti i core disponibili per parallelizzare la build. Al termine, la libreria viene installata in /usr/local/modsecurity/.
Verifica l’installazione controllando il percorso della libreria condivisa:
ls -la /usr/local/modsecurity/lib/
# Output atteso:
# libmodsecurity.a
# libmodsecurity.so -> libmodsecurity.so.3
# libmodsecurity.so.3 -> libmodsecurity.so.3.0.x
Problema comune n. 1: Se make fallisce con error: 'yajl_config' undeclared, la versione di libyajl-dev non è compatibile. Installa la versione specifica:
sudo apt install libyajl2 libyajl-dev
sudo ldconfig
Step 3: Costruire il connettore ModSecurity per NGINX
Il connettore è un modulo dinamico per NGINX che fa da bridge tra il processo NGINX e la libreria ModSecurity. Deve essere compilato contro la stessa versione di NGINX che eseguirai in produzione.
cd /usr/local/src
sudo git clone https://github.com/SpiderLabs/ModSecurity-nginx.git
# Scarica i sorgenti della stessa versione di NGINX che hai installato
NGINX_VER=$(nginx -v 2>&1 | grep -oP '[\d.]+')
sudo wget "http://nginx.org/download/nginx-${NGINX_VER}.tar.gz"
sudo tar -xzf "nginx-${NGINX_VER}.tar.gz"
cd "nginx-${NGINX_VER}"
sudo ./configure \
--with-compat \
--add-dynamic-module=../ModSecurity-nginx
sudo make modules
sudo cp objs/ngx_http_modsecurity_module.so /usr/lib/nginx/modules/
sudo chmod 0644 /usr/lib/nginx/modules/ngx_http_modsecurity_module.so
L’opzione --with-compat abilita la modalità di compatibilità ABI, indispensabile per caricare il modulo dinamico nell’NGINX installato via apt. Senza questa opzione, NGINX rifiuterà il modulo al caricamento con un errore di versione.
Problema comune n. 2: Se ottieni nginx: [emerg] module ... version 1024000 instead of 1024002, la versione dei sorgenti NGINX scaricata non corrisponde all’NGINX installato. Usa esattamente la stessa versione:
nginx -v
# nginx version: nginx/1.24.0
# Scarica NGINX 1.24.0, non altra versione
Step 4: Configurare NGINX con ModSecurity
Crea la directory di configurazione di ModSecurity e copia i file base:
sudo mkdir -p /etc/nginx/modsec
# Copia i file di configurazione base da ModSecurity
sudo cp /usr/local/src/ModSecurity/modsecurity.conf-recommended \
/etc/nginx/modsec/modsecurity.conf
sudo cp /usr/local/src/ModSecurity/unicode.mapping \
/etc/nginx/modsec/
Carica il modulo dinamico all’inizio di /etc/nginx/nginx.conf aggiungendo questa riga nella sezione principale (prima di qualsiasi blocco events o http):
load_module modules/ngx_http_modsecurity_module.so;
Configura il server block per il tuo dominio. Sostituisci example.com con il tuo dominio e assicurati che i percorsi dei certificati SSL corrispondano a quelli generati da Certbot:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/modsecurity.conf;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90s;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
La direttiva proxy_set_header X-Forwarded-For è fondamentale: passa l’IP reale del client all’app Node.js, che altrimenti vedrebbe solo l’indirizzo localhost di NGINX. In Express, abilita la fiducia nel proxy con app.set('trust proxy', 1).
Step 5: Installare OWASP Core Rule Set
L’OWASP Core Rule Set (CRS) è il set di regole open source più usato per ModSecurity. Copre le vulnerabilità OWASP Top 10, incluse SQL injection, XSS, command injection, path traversal e remote file inclusion. Senza CRS, ModSecurity è solo un engine senza regole.
sudo git clone https://github.com/coreruleset/coreruleset.git \
/etc/nginx/owasp-crs
# Copia il file di configurazione esempio
sudo cp /etc/nginx/owasp-crs/crs-setup.conf.example \
/etc/nginx/owasp-crs/crs-setup.conf
Il CRS funziona su un sistema di punteggio anomalia: ogni regola che scatta aggiunge un punteggio alla richiesta. Quando il punteggio supera la soglia (default 5 per richieste in ingresso), la richiesta viene bloccata. Questo approccio riduce i falsi positivi rispetto al blocco rule-per-rule.
Il Paranoia Level controlla quante regole vengono attivate. Il livello 1 (default) copre le minacce più comuni con pochissimi falsi positivi. Il livello 4 attiva tutti i controlli con rischio elevato di bloccare traffico legittimo:
| Paranoia Level | Regole attive | Falsi positivi | Caso d’uso |
|---|---|---|---|
| 1 (default) | Solo le più critiche | Molto bassi | Produzione, primo deploy |
| 2 | Moderatamente esteso | Bassi | App con input utente complesso |
| 3 | Ampiamente esteso | Medi | Ambienti ad alta sicurezza |
| 4 | Tutte le regole | Alti | Solo con tuning approfondito |
Modifica il file crs-setup.conf per impostare il Paranoia Level:
# In /etc/nginx/owasp-crs/crs-setup.conf
SecAction \
"id:900000, \
phase:1, \
nolog, \
pass, \
t:none, \
setvar:tx.paranoia_level=1"
Ora aggiorna /etc/nginx/modsec/modsecurity.conf per includere il CRS. Modifica la riga SecRuleEngine e aggiungi gli include alla fine del file:
# In /etc/nginx/modsec/modsecurity.conf
# Cambia da DetectionOnly a On quando sei pronto per il blocco
SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecRequestBodyLimitAction Reject
SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsec_audit.log
SecAuditLogFormat JSON
SecAuditLogParts ABIJDEFHZ
# Include OWASP CRS
Include /etc/nginx/owasp-crs/crs-setup.conf
Include /etc/nginx/owasp-crs/rules/*.conf
Step 6: Preparare l’applicazione Node.js
Prima di testare il WAF, assicurati che l’app Node.js sia configurata per funzionare correttamente dietro un reverse proxy. Ecco un’app Express minimale che funzionerà con la nostra configurazione:
// app.js - Express app protetta da WAF
const express = require('express');
const app = express();
// Fiducia nel reverse proxy NGINX
app.set('trust proxy', 1);
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Endpoint di test: riceve parametri GET
app.get('/search', (req, res) => {
const query = req.query.q || '';
res.json({
query: query,
clientIP: req.ip,
forwardedFor: req.headers['x-forwarded-for']
});
});
// Endpoint di test: riceve body JSON
app.post('/data', (req, res) => {
res.json({ received: req.body });
});
app.listen(3000, '127.0.0.1', () => {
console.log('App in ascolto su 127.0.0.1:3000');
});
module.exports = app;
Nota che l’app è vincolata a 127.0.0.1:3000 (non a 0.0.0.0): questo impedisce connessioni dirette all’app bypassing il WAF. Solo NGINX, in esecuzione sullo stesso server, può raggiungerla.
Installa le dipendenze e avvia l’app con PM2 per garantire il riavvio automatico:
npm install express
npm install -g pm2
pm2 start app.js --name "node-app"
pm2 startup
pm2 save
Step 7: Testare la configurazione NGINX
Prima di riavviare NGINX, verifica la sintassi della configurazione. Qualsiasi errore qui impedirebbe il riavvio del server web:
sudo nginx -t
# Output atteso:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful
sudo systemctl reload nginx
Problema comune n. 3: Se vedi unknown directive "modsecurity", il modulo non è caricato correttamente. Verifica la riga load_module in nginx.conf e che il file .so esista:
ls -la /usr/lib/nginx/modules/ngx_http_modsecurity_module.so
# Deve esistere e avere permessi 0644
Step 8: Testare il WAF con attacchi simulati
Con il WAF attivo, testa che blocchi correttamente i payload malevoli. Prima verifica che le richieste legittime passino:
# Richiesta legittima: deve ritornare 200
curl -i "https://example.com/search?q=node.js"
# SQL injection: deve ritornare 403
curl -i "https://example.com/search?q=1%20OR%201=1"
# XSS: deve ritornare 403
curl -i "https://example.com/search?q="
# Path traversal: deve ritornare 403
curl -i "https://example.com/search?file=../../etc/passwd"
# Command injection: deve ritornare 403
curl -i "https://example.com/search?cmd=;cat%20/etc/shadow"
Per una verifica più completa del WAF contro SQL injection, usa sqlmap in modalità non distruttiva (solo rilevamento, senza exploit):
# Test sqlmap: verifica quante richieste vengono bloccate
sqlmap -u "https://example.com/search?q=test" \
--batch \
--level=3 \
--risk=2 \
--timeout=10 \
2>&1 | tail -20
Con CRS al Paranoia Level 1, sqlmap dovrebbe rilevare il WAF e dichiarare il target protetto. Usa nikto per una scansione più ampia delle vulnerabilità potenziali:
nikto -h https://example.com -ssl -Format txt
Output tipico quando il WAF funziona: Nikto rileverà il WAF e molti dei suoi test torneranno con 403 Forbidden invece dei codici che indicano vulnerabilità.
Step 9: Analizzare i log di audit ModSecurity
Ogni richiesta bloccata viene registrata in formato JSON nel file di audit. Analizza i log per capire cosa viene bloccato e perché:
# Visualizza gli ultimi blocchi in formato leggibile
sudo tail -f /var/log/modsec_audit.log | python3 -m json.tool
# Oppure con jq per un output più compatto
sudo tail -100 /var/log/modsec_audit.log | \
jq '{id:.transaction.id, ip:.transaction.client_ip, uri:.request.uri, rule:.matched_rules[0].id, msg:.matched_rules[0].message}'
Un record di log tipico per un’iniezione SQL bloccata ha questa struttura:
{
"transaction": {
"id": "abc123def456",
"client_ip": "203.0.113.42",
"time": "2026-06-20T14:32:11+00:00"
},
"request": {
"method": "GET",
"uri": "/search?q=1%20OR%201=1",
"headers": { "User-Agent": "curl/8.x" }
},
"matched_rules": [{
"id": "942100",
"message": "SQL Injection Attack Detected via libinjection",
"severity": "CRITICAL",
"tags": ["attack-sqli", "OWASP_CRS"]
}],
"response": {
"status": 403
}
}
I campi più importanti da monitorare sono matched_rules[].id (l’ID della regola CRS che ha scattato) e matched_rules[].message (la descrizione della minaccia rilevata). Usa questi dati per identificare i pattern di attacco più frequenti contro la tua app.
Step 10: Ridurre i falsi positivi con le esclusioni
Il CRS può bloccare traffico legittimo, specialmente con editor WYSIWYG, upload di file, o API che accettano input con caratteri speciali. La strategia corretta non è abbassare il Paranoia Level, ma creare esclusioni mirate.
Inizia sempre con SecRuleEngine DetectionOnly per una settimana, raccogliendo i log senza bloccare. Poi analizza i falsi positivi:
# Conta quante volte scatta ogni regola (per identificare i falsi positivi più frequenti)
sudo cat /var/log/modsec_audit.log | \
python3 -c "
import json, sys, collections
rules = collections.Counter()
for line in sys.stdin:
try:
data = json.loads(line)
for rule in data.get('matched_rules', []):
rules[rule['id']] += 1
except: pass
for rule_id, count in rules.most_common(10):
print(f'Rule {rule_id}: {count} occorrenze')
"
Per escludere una regola specifica solo per un determinato endpoint (ad esempio, un endpoint che accetta HTML da un editor), crea un file di esclusione:
# /etc/nginx/modsec/exclusions.conf
# Escludi le regole XSS solo per l'endpoint dell'editor (accetta HTML legittimo)
SecRule REQUEST_URI "@beginsWith /api/editor" \
"id:10001, \
phase:1, \
pass, \
nolog, \
ctl:ruleRemoveByTag=attack-xss"
# Escludi un parametro specifico dalla regola 942100 (SQLi via libinjection)
# solo se il tuo motore di ricerca accetta query avanzate con operatori
SecRuleUpdateTargetById 942100 "!ARGS:advanced_query"
Aggiungi il file di esclusione nell’include di modsecurity.conf, dopo i CRS rules:
# In modsecurity.conf, dopo Include /etc/nginx/owasp-crs/rules/*.conf
Include /etc/nginx/modsec/exclusions.conf
Regola d’oro: le esclusioni devono essere sempre il più specifiche possibile. Escludere una regola per un intero dominio invece che per un singolo endpoint lascia buchi di sicurezza significativi. Usa sempre REQUEST_URI "@beginsWith /percorso/specifico" come condizione di partenza.
Step 11: Deploy con Docker Compose
Per ambienti containerizzati, il pattern raccomandato separa NGINX+ModSecurity dall’app Node.js in container distinti, collegati da una rete interna Docker:
# docker-compose.yml
version: '3.8'
services:
waf:
image: nginx:1.24
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/modsec:/etc/nginx/modsec:ro
- ./nginx/owasp-crs:/etc/nginx/owasp-crs:ro
- modsec_logs:/var/log/modsecurity
- ./certs:/etc/letsencrypt:ro
depends_on:
- app
networks:
- internal
app:
build: ./node-app
expose:
- "3000"
environment:
- NODE_ENV=production
networks:
- internal
restart: unless-stopped
volumes:
modsec_logs:
networks:
internal:
driver: bridge
Il container dell’app Node.js non espone nessuna porta all’host (usa expose invece di ports): è accessibile solo dal container WAF tramite la rete interna. Questo garantisce che tutto il traffico passi obbligatoriamente per ModSecurity.
Il Dockerfile per Node.js dovrebbe usare un’immagine base minimal e un utente non-root:
# node-app/Dockerfile
FROM node:22-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "app.js"]
Step 12: Monitoraggio, alerting e aggiornamenti CRS
Un WAF non è un firewall “configura e dimentica”: richiede monitoraggio continuo per essere efficace. Imposta un monitoraggio di base con logrotate per i log di audit:
# /etc/logrotate.d/modsecurity
/var/log/modsec_audit.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
postrotate
nginx -s reopen
endscript
}
Per ricevere alert in tempo reale sugli attacchi bloccati, usa questo script bash che controlla i log ogni minuto e invia una notifica se il numero di blocchi supera la soglia:
#!/bin/bash
# /opt/waf-monitor.sh - Controlla attacchi nel minuto precedente
THRESHOLD=50 # Alert se più di 50 blocchi in 1 minuto
LOG="/var/log/modsec_audit.log"
ALERT_EMAIL="[email protected]"
BLOCKS=$(sudo tail -1000 "$LOG" | \
python3 -c "
import json, sys
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(minutes=1)
count = 0
for line in sys.stdin:
try:
data = json.loads(line)
ts = data.get('transaction',{}).get('time','')
if ts and data.get('response',{}).get('status') == 403:
count += 1
except: pass
print(count)
")
if [ "$BLOCKS" -gt "$THRESHOLD" ]; then
echo "WAF Alert: $BLOCKS blocchi nell'ultimo minuto" | \
mail -s "Alert WAF - Possibile attacco" "$ALERT_EMAIL"
fi
Aggiungi lo script al crontab per eseguirlo ogni minuto:
chmod +x /opt/waf-monitor.sh
echo "* * * * * root /opt/waf-monitor.sh" | sudo tee /etc/cron.d/waf-monitor
Aggiorna il CRS regolarmente. Il team OWASP rilascia aggiornamenti che coprono nuove CVE e vettori di attacco emergenti:
cd /etc/nginx/owasp-crs
sudo git pull origin main
sudo nginx -t && sudo systemctl reload nginx
echo "CRS aggiornato il $(date)"
Errori comuni e troubleshooting
Questi sono gli 8 problemi più frequenti nell’installazione di ModSecurity e le relative soluzioni:
| Errore | Causa | Soluzione |
|---|---|---|
unknown directive "modsecurity" | Modulo non caricato | Aggiungi load_module modules/ngx_http_modsecurity_module.so; in nginx.conf |
| Errore ABI del modulo dinamico | Versione NGINX sorgente != installata | Usa esattamente la stessa versione NGINX per compilare il connettore |
| ModSecurity non blocca nulla | SecRuleEngine DetectionOnly | Cambia in SecRuleEngine On |
| Richieste legittime bloccate (403) | Falsi positivi CRS | Analizza i log, crea esclusioni mirate per gli endpoint interessati |
make: libyajl not found | Dipendenza mancante | sudo apt install libyajl-dev |
| Log di audit vuoti | SecAuditEngine Off | Imposta SecAuditEngine RelevantOnly |
| Node.js vede IP 127.0.0.1 | Proxy non configurato in Express | app.set('trust proxy', 1) |
| CRS non caricato | Path include errato | Verifica il percorso in modsecurity.conf, esegui nginx -t |
Problema comune n. 4: il body JSON delle richieste POST non viene ispezionato. Assicurati che SecRequestBodyAccess On sia impostato e che il Content-Type sia tra quelli supportati (application/json, application/x-www-form-urlencoded, multipart/form-data).
Problema comune n. 5: file di upload rifiutati con 413 Request Entity Too Large. Il limite di default è 13 MB. Aumentalo nel file di configurazione:
# In modsecurity.conf
SecRequestBodyLimit 52428800 # 50 MB
SecRequestBodyNoFilesLimit 131072 # 128 KB per i campi non-file
# In nginx.conf, nella sezione http o server
client_max_body_size 50m;
Tecniche di bypass WAF e come prevenirle
Un WAF non è impenetrabile. Gli attaccanti esperti usano tecniche di bypass specifiche. Conoscerle ti aiuta a rafforzare la configurazione:
Encoding multiplo: l’attaccante codifica il payload più volte (URL encoding + base64 + HTML entities) sperando che il WAF decodifichi una volta sola. ModSecurity con CRS gestisce la decodifica multipla, ma assicurati che SecRequestBodyAccess On e la trasformazione t:urlDecodeUni siano attive nelle regole.
HTTP Parameter Pollution: inviare lo stesso parametro più volte con valori diversi, sperando che il WAF controlli solo la prima occorrenza mentre l’app usa l’ultima. NGINX/ModSecurity gestisce questo unendo i valori, ma verifica il comportamento della tua app con parametri duplicati.
Chunked Transfer Encoding: inviare il corpo della richiesta in chunk. ModSecurity deve ricevere l’intero corpo prima di ispezionarlo. Verifica che SecRequestBodyAccess On sia attivo e che il buffer sia adeguato.
JSON Nested Encoding: annidare il payload malevolo in strutture JSON profonde. Attiva SecRule REQUEST_HEADERS:Content-Type per ispezionare il tipo di contenuto e usa @rx per pattern matching sui body JSON.
Case Variation e Whitespace: usare SeLeCt invece di SELECT, o inserire commenti SQL /**/ tra le parole chiave. L’OWASP CRS usa libinjection, una libreria specializzata nel rilevamento di SQL injection che è resistente a queste variazioni per design.
Confronto WAF: ModSecurity vs. soluzioni cloud
Prima di impegnarsi su ModSecurity self-hosted, è utile capire quando ha senso rispetto ai WAF cloud:
| Soluzione WAF | Costo mensile | True Positive Rate | False Positive Rate | Controllo |
|---|---|---|---|---|
| ModSecurity + CRS (self-hosted) | €0 (solo infrastruttura) | 99,56% (profilo critico) | 0,563% | Completo |
| Cloudflare WAF (Pro) | da $20/mese | ~97-98% | 0,06% | Limitato |
| AWS WAF | da $5/mese + $0,60/1M req | 97,5% | Variabile | Medio |
| Azure WAF | da €35/mese | 97,5% | Variabile | Medio |
| F5 NGINX App Protect | Commerciale, su licenza | 97,8% | Basso | Alto |
ModSecurity self-hosted è la scelta giusta quando hai requisiti di conformità che richiedono che il traffico non lasci la tua infrastruttura (GDPR, NIS2), quando vuoi personalizzazione completa delle regole, o quando il volume di traffico renderebbe i WAF cloud costosi. I WAF cloud sono preferibili quando non hai personale per gestire aggiornamenti e tuning, o quando hai già infrastruttura cloud con WAF integrato.
Per le PMI italiane soggette al GDPR e alla Direttiva NIS2, un WAF auto-ospitato offre anche un vantaggio di conformità: nessun dato del traffico lascia il perimetro aziendale. Puoi approfondire le implicazioni normative nella nostra guida NIS2 Italia 2026.
Suggerimenti avanzati
GeoIP blocking: ModSecurity supporta il blocco per paese tramite il database MaxMind GeoIP2. Utile per bloccare traffico da paesi senza utenti legittimi. Installa il modulo MaxMind e crea una regola:
SecGeoLookupDb /etc/nginx/GeoIP/GeoLite2-Country.mmdb
SecRule GEO:COUNTRY_CODE "@rx ^(CN|RU|KP)$" \
"id:10100, \
phase:1, \
deny, \
status:403, \
log, \
msg:'Blocked by country GeoIP'"
Rate limiting a livello WAF: combina ModSecurity con la direttiva limit_req di NGINX per un doppio strato di protezione contro brute force e DDoS applicativo:
# In nginx.conf, nella sezione http
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
# Nel server block
location /api/login {
limit_req zone=api burst=5 nodelay;
modsecurity on;
proxy_pass http://127.0.0.1:3000;
}
Regole CRS personalizzate per l’applicazione: dopo aver configurato CRS, aggiungi regole specifiche per la tua app che il CRS non copre. Ad esempio, blocca tentativi di accesso con credenziali note compromesse:
# /etc/nginx/modsec/custom-rules.conf
SecRule REQUEST_URI "@rx ^/api/login$" \
"chain, id:20001, phase:2, deny, status:401, \
log, msg:'Login con password nota compromessa'"
SecRule REQUEST_BODY "@rx \"password\":\"(123456|password|admin|qwerty)\"" ""
Integrazione con SIEM: esporta i log di ModSecurity verso Wazuh o Elasticsearch per la correlazione degli eventi. ModSecurity supporta l’output JSON che si integra nativamente con qualsiasi SIEM moderno. Consulta la nostra guida su Wazuh: Installazione SIEM/XDR per il setup completo.
Benchmark di performance: misura l’impatto del WAF sulla latenza con wrk prima e dopo l’attivazione:
# Senza WAF (proxy diretto)
wrk -t4 -c100 -d30s http://127.0.0.1:3000/search?q=test
# Con WAF (via NGINX+ModSecurity)
wrk -t4 -c100 -d30s https://example.com/search?q=test
In produzione tipica, ModSecurity con CRS al Paranoia Level 1 aggiunge 2-5 ms di latenza per richiesta su hardware moderno. Con Paranoia Level 3-4, l’overhead può salire a 10-20 ms per richieste con body complessi.
Copertura correlata
Per approfondire la sicurezza del tuo stack Node.js, consulta questi articoli correlati:
- XSS in Node.js: Prevenirlo in 12 Step — difesa in profondità lato applicativo contro il cross-site scripting
- SQL Injection in Node.js: Come Prevenirla in 12 Step — parametrizzazione delle query e ORM sicuri
- Validazione Input con Zod, Joi e express-validator — sanitizzazione e validazione a livello applicativo
- Let’s Encrypt e Certbot: HTTPS Gratis in 10 Step — certificati TLS per il server NGINX davanti al WAF
- OpenSSL 3.5 LTS: Chiavi e Certificati in 12 Step — gestione di certificati X.509 per ambienti enterprise
- Wazuh: Installazione e Configurazione SIEM/XDR — integra i log WAF in un sistema SIEM completo
- Nessus vs OpenVAS: Scanner di Vulnerabilità — scansione proattiva delle vulnerabilità
FAQ: Web Application Firewall con ModSecurity
ModSecurity 3.x è stabile per la produzione?
Sì. ModSecurity 3.x (ramo v3/master) è ampiamente usato in produzione dal 2018. Il ramo v2 è ancora in uso ma non riceve nuove funzionalità. Per nuove installazioni con NGINX, usa sempre v3.
Quale Paranoia Level devo usare?
Parti dal livello 1 per almeno due settimane in modalità DetectionOnly. Analizza i falsi positivi, crea le esclusioni necessarie, poi passa a SecRuleEngine On. Aumenta al livello 2 solo dopo aver stabilizzato il livello 1. La maggior parte delle app in produzione funziona bene al livello 1 o 2.
Il WAF protegge dalle vulnerabilità nelle dipendenze npm?
Parzialmente. Il WAF può bloccare i payload di exploit noti che arrivano via HTTP (come exploit di Log4Shell o SSRF). Non può proteggere da vulnerabilità che si manifestano internamente senza payload HTTP specifici. Per la sicurezza delle dipendenze npm, usa npm audit e strumenti di Software Composition Analysis (SCA).
Devo usare un WAF se uso già Cloudflare?
La difesa in profondità (defense in depth) raccomanda entrambi i livelli: Cloudflare WAF davanti per il traffico globale e ModSecurity sul server per un secondo strato di protezione. Se un attaccante bypassa Cloudflare (cambio DNS, leak del tuo IP reale), ModSecurity è l’ultima linea di difesa.
Come aggiorno le regole CRS senza downtime?
Con nginx -s reload invece di systemctl restart nginx: NGINX esegue un graceful reload, finendo di servire le richieste in corso prima di applicare la nuova configurazione. Automatizza l’aggiornamento con un cron job settimanale che esegue git pull nel repository CRS seguito da nginx -t && nginx -s reload.
ModSecurity può gestire WebSocket?
No. ModSecurity ispeziona solo il traffico HTTP/HTTPS standard. Le connessioni WebSocket (dopo l’handshake HTTP Upgrade) non vengono ispezionate. Per proteggere i WebSocket, implementa validazione e autenticazione a livello applicativo. Separa gli endpoint WebSocket dagli endpoint REST nel reverse proxy NGINX.
Quanto costa gestire ModSecurity in produzione?
Il software è gratuito. Il costo è operativo: 2-4 ore/mese per aggiornamenti CRS, revisione log e tuning dei falsi positivi. Rispetto ai costi di un cloud WAF commerciale (da $20 a $400/mese per traffico significativo), il break-even è raggiunto in 2-3 mesi anche considerando il tempo del sistemista.
Il WAF è sufficiente per la conformità NIS2?
Il WAF è uno degli strumenti richiesti dalla NIS2 per la gestione del rischio tecnico, ma non è sufficiente da solo. La NIS2 richiede anche incident response, gestione delle vulnerabilità, controllo degli accessi e MFA, backup sicuri e formazione del personale. Il WAF copre il requisito di “misure tecniche per prevenire gli attacchi HTTP”.
Progetto completo: stack WAF production-ready
Qui sotto trovi la struttura completa di un progetto Node.js protetto da WAF, pronto per essere deployato in produzione. Organizza la directory del progetto in questo modo:
my-app/
├── docker-compose.yml
├── docker-compose.prod.yml
├── nginx/
│ ├── nginx.conf
│ ├── sites-enabled/
│ │ └── app.conf
│ └── modsec/
│ ├── modsecurity.conf
│ ├── crs-setup.conf
│ └── exclusions.conf
├── node-app/
│ ├── Dockerfile
│ ├── package.json
│ ├── app.js
│ └── routes/
│ ├── auth.js
│ └── api.js
├── certs/
│ └── .gitkeep
└── logs/
└── .gitkeep
Il file nginx.conf completo con tutte le impostazioni di sicurezza consigliate:
# nginx/nginx.conf
load_module modules/ngx_http_modsecurity_module.so;
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Header di sicurezza globali
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Nascondi la versione NGINX
server_tokens off;
# Limiti di dimensione
client_max_body_size 10m;
client_body_timeout 12;
client_header_timeout 12;
send_timeout 10;
# Compressione
gzip on;
gzip_comp_level 5;
gzip_types text/plain application/json text/css;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=general:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=connections:10m;
# Log formato JSON per compatibilità SIEM
log_format json_combined escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes_sent":$bytes_sent,'
'"request_time":$request_time,'
'"user_agent":"$http_user_agent"}';
access_log /var/log/nginx/access.log json_combined;
include /etc/nginx/sites-enabled/*.conf;
}
Il file sites-enabled/app.conf con la configurazione del virtual host e il WAF abilitato:
# nginx/sites-enabled/app.conf
upstream nodejs_backend {
server app:3000;
keepalive 32;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL/TLS
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
# ModSecurity
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/modsecurity.conf;
# Rate limiting per gli endpoint critici
location /api/auth/ {
limit_req zone=auth burst=3 nodelay;
limit_conn connections 3;
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
L’app Node.js completa con route di autenticazione e API, pronta per il deploy dietro il WAF:
// node-app/app.js
'use strict';
const express = require('express');
const helmet = require('helmet');
const { body, query, validationResult } = require('express-validator');
const app = express();
// Fiducia nel reverse proxy NGINX
app.set('trust proxy', 1);
// Middleware di sicurezza (strato applicativo, in aggiunta al WAF)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
}
},
hsts: false, // Gestito da NGINX
}));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Health check (non protetto da rate limiting in NGINX)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Ricerca con validazione input
app.get('/search',
query('q')
.trim()
.isLength({ min: 1, max: 200 })
.escape(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { q } = req.query;
res.json({ query: q, results: [], clientIP: req.ip });
}
);
// Route API
app.use('/api', require('./routes/api'));
app.use('/api/auth', require('./routes/auth'));
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
console.log(`App in ascolto sulla porta ${PORT}`);
});
module.exports = app;
Nota come l’app usa helmet per aggiungere un secondo strato di header di sicurezza (Content-Security-Policy, X-Frame-Options, ecc.) anche a livello applicativo. Questo segue il principio della difesa in profondità: il WAF blocca i payload malevoli, helmet protegge dai misuse del browser, la validazione input (express-validator) garantisce la correttezza dei dati prima che raggiungano la business logic.
Per installare le dipendenze dell’app Node.js:
cd node-app
npm install express helmet express-validator
npm install --save-dev jest supertest
Test automatici per verificare il WAF
Automatizza il test del WAF con uno script che verifica i casi d’uso più importanti. Questo script può essere integrato nella CI/CD pipeline per garantire che il WAF sia attivo e funzionante prima di ogni deployment:
#!/bin/bash
# waf-smoke-test.sh - Verifica che il WAF blocchi gli attacchi noti
BASE_URL="${1:-https://example.com}"
PASS=0
FAIL=0
check() {
local description="$1"
local url="$2"
local expected_status="$3"
local method="${4:-GET}"
local body="$5"
if [ "$method" = "POST" ]; then
actual=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST -H 'Content-Type: application/json' \
-d "$body" "$url")
else
actual=$(curl -s -o /dev/null -w '%{http_code}' "$url")
fi
if [ "$actual" = "$expected_status" ]; then
echo "PASS | $description (HTTP $actual)"
PASS=$((PASS + 1))
else
echo "FAIL | $description (atteso $expected_status, ottenuto $actual)"
FAIL=$((FAIL + 1))
fi
}
echo "=== WAF Smoke Test: $BASE_URL ==="
# Traffico legittimo: deve passare
check "Richiesta GET normale" "$BASE_URL/search?q=nodejs" "200"
check "Health check" "$BASE_URL/health" "200"
# SQL Injection: deve essere bloccata (403)
check "SQL injection GET param" \
"$BASE_URL/search?q=1%20OR%201=1--" "403"
check "SQL injection UNION" \
"$BASE_URL/search?q=1%20UNION%20SELECT%20null,null--" "403"
check "SQL injection blind" \
"$BASE_URL/search?q=1%27%20AND%20sleep(5)--" "403"
# XSS: deve essere bloccata (403)
check "XSS script tag" \
"$BASE_URL/search?q=%3Cscript%3Ealert(1)%3C/script%3E" "403"
check "XSS evento DOM" \
"$BASE_URL/search?q=%22%3E%3Cimg%20onerror%3Dalert(1)%3E" "403"
# Path traversal: deve essere bloccata (403)
check "Path traversal Unix" \
"$BASE_URL/search?file=../../etc/passwd" "403"
check "Path traversal encoded" \
"$BASE_URL/search?file=%2e%2e%2f%2e%2e%2fetc%2fpasswd" "403"
# Command injection: deve essere bloccata (403)
check "Command injection" \
"$BASE_URL/search?q=;cat%20/etc/shadow" "403"
echo ""
echo "=== Risultati: $PASS PASS, $FAIL FAIL ==="
[ "$FAIL" -eq 0 ] && echo "WAF OK: tutti i test superati." || echo "WAF WARNING: $FAIL test falliti."
exit "$FAIL"
Esegui lo script come parte della pipeline di deployment:
chmod +x waf-smoke-test.sh
./waf-smoke-test.sh https://example.com
# Output atteso:
# PASS | Richiesta GET normale (HTTP 200)
# PASS | Health check (HTTP 200)
# PASS | SQL injection GET param (HTTP 403)
# PASS | SQL injection UNION (HTTP 403)
# PASS | SQL injection blind (HTTP 403)
# PASS | XSS script tag (HTTP 403)
# PASS | XSS evento DOM (HTTP 403)
# PASS | Path traversal Unix (HTTP 403)
# PASS | Path traversal encoded (HTTP 403)
# PASS | Command injection (HTTP 403)
#
# Risultati: 10 PASS, 0 FAIL
# WAF OK: tutti i test superati.
Integra questo test in GitHub Actions o GitLab CI aggiungendo un job di smoke test post-deployment che fallisce il pipeline se il WAF non blocca i payload critici. Un WAF non funzionante è peggio di nessun WAF: crea una falsa sensazione di sicurezza.
Performance e tuning avanzato
ModSecurity con CRS aggiunge overhead di CPU e latenza. Ecco le principali ottimizzazioni per ambienti ad alto traffico:
Disabilita l’ispezione del body in risposta: in quasi tutti i casi, ispezionare le risposte non aggiunge valore significativo ma aumenta l’overhead. Mantieni SecResponseBodyAccess Off.
Usa SecRule con tag specifici per escludere endpoint interni: gli endpoint di health check, metrics e admin (accessibili solo da IP interni) non hanno bisogno di essere ispezionati dal WAF. Escludili esplicitamente:
# Escludi il health check dal WAF (chiamato solo da load balancer interni)
SecRule REMOTE_ADDR "@ipMatch 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" \
"id:10200, phase:1, pass, nolog, \
ctl:ruleEngine=Off"
# Oppure esclusione per URI
SecRule REQUEST_URI "@rx ^/(health|metrics|readyz)$" \
"id:10201, phase:1, pass, nolog, \
ctl:ruleEngine=Off"
Limite del body PCRE: le espressioni regolari PCRE usate da CRS possono impazzire su input molto grandi con strutture di matching complesse (ReDoS). Il parametro SecPcreMatchLimit limita il numero di operazioni PCRE per prevenire blocchi del worker process:
# In modsecurity.conf
SecPcreMatchLimit 500000
SecPcreMatchLimitRecursion 500000
Worker processes e buffer: NGINX dovrebbe avere un numero di worker process uguale al numero di CPU core, con buffer di proxy ottimizzati per il traffico tipico:
# In nginx.conf - sezione http
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
proxy_temp_file_write_size 8k;
Per applicazioni ad alto volume (oltre 1.000 req/s), considera l’utilizzo di più worker NGINX con il modulo di bilanciamento del carico davanti a più istanze Node.js. Il WAF rimane un singolo punto, ma NGINX gestisce efficientemente il proxy verso un upstream pool:
upstream nodejs_cluster {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
keepalive 64;
}
Con quattro istanze Node.js e NGINX + ModSecurity, un server con 4 core e 8 GB RAM gestisce tipicamente 800-1.200 req/s con payload REST di piccole dimensioni e CRS al Paranoia Level 1. Per traffico superiore, la soluzione corretta è scalare orizzontalmente con un load balancer davanti a più server NGINX+WAF.
Conformità NIS2 e GDPR con il WAF
Per le aziende italiane soggette alla Direttiva NIS2 (recepita in Italia con il D.Lgs. 138/2024) e al GDPR, l’implementazione di un WAF contribuisce a soddisfare specifici requisiti normativi. L’articolo 21 della NIS2 richiede misure tecniche appropriate per gestire i rischi per la sicurezza, tra cui la protezione delle applicazioni web.
Il WAF con ModSecurity e CRS documenta automaticamente ogni tentativo di attacco bloccato nel log di audit. Questi log sono preziosi in caso di audit NIS2 o in caso di notifica di incidente: dimostrano che l’azienda ha implementato controlli tecnici adeguati. Conserva i log per almeno 12 mesi (NIS2 richiede la tracciabilità degli incidenti):
# Logrotate per conservare i log 12 mesi (90 giorni * 4 rotazioni = ~1 anno)
/var/log/modsec_audit.log {
daily
rotate 365
compress
dateext
dateformat -%Y%m%d
missingok
notifempty
postrotate
nginx -s reopen 2>/dev/null || true
endscript
}
Per la conformità GDPR, fai attenzione ai dati personali che potrebbero finire nei log di audit ModSecurity: indirizzi email, nomi utente, token JWT o altri dati personali inclusi nei parametri delle richieste. Configura ModSecurity per anonimizzare gli IP nei log se richiesto dalla tua policy sulla privacy:
# Anonimizza l'ultimo ottetto dell'IP nei log
# (aggiunge una regola di trasformazione prima del logging)
SecRule REMOTE_ADDR "@rx ^(\d+\.\d+\.\d+)\.\d+$" \
"id:10300, phase:5, pass, nolog, \
setenv:ANON_IP=%{TX.1}.0"
Considera anche che i log di sicurezza sono considerati dati personali ai sensi del GDPR se consentono di identificare gli individui tramite l’indirizzo IP. Documenta nel Registro dei Trattamenti il trattamento dei log WAF, la base giuridica (interesse legittimo per la sicurezza informatica) e il periodo di conservazione.
Per risorse aggiuntive sulle normative di sicurezza italiane ed europee, consulta la nostra guida su NIS2 Italia 2026 e il confronto NIS2 vs DORA.




