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:

ClientNGINX (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).

ComponenteRuoloVersione 2026
ModSecurityEngine WAF open sourcev3/master (branch stabile)
ModSecurity-nginxConnettore modulo dinamicoCompatibile con NGINX 1.24.x
OWASP CRSSet di regole per OWASP Top 10Ultima versione dal repo GitHub
NGINXReverse proxy + TLS termination1.24.0
Node.jsBackend applicativo22.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 LevelRegole attiveFalsi positiviCaso d’uso
1 (default)Solo le più criticheMolto bassiProduzione, primo deploy
2Moderatamente estesoBassiApp con input utente complesso
3Ampiamente estesoMediAmbienti ad alta sicurezza
4Tutte le regoleAltiSolo 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:

ErroreCausaSoluzione
unknown directive "modsecurity"Modulo non caricatoAggiungi load_module modules/ngx_http_modsecurity_module.so; in nginx.conf
Errore ABI del modulo dinamicoVersione NGINX sorgente != installataUsa esattamente la stessa versione NGINX per compilare il connettore
ModSecurity non blocca nullaSecRuleEngine DetectionOnlyCambia in SecRuleEngine On
Richieste legittime bloccate (403)Falsi positivi CRSAnalizza i log, crea esclusioni mirate per gli endpoint interessati
make: libyajl not foundDipendenza mancantesudo apt install libyajl-dev
Log di audit vuotiSecAuditEngine OffImposta SecAuditEngine RelevantOnly
Node.js vede IP 127.0.0.1Proxy non configurato in Expressapp.set('trust proxy', 1)
CRS non caricatoPath include erratoVerifica 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 WAFCosto mensileTrue Positive RateFalse Positive RateControllo
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 WAFda $5/mese + $0,60/1M req97,5%VariabileMedio
Azure WAFda €35/mese97,5%VariabileMedio
F5 NGINX App ProtectCommerciale, su licenza97,8%BassoAlto

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:

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.