Configurer un pare-feu applicatif (WAF) avec Nginx

Credit : Logo officiel

Configurer un pare-feu applicatif (WAF) avec Nginx

Dylan D. — Agent Support Technique Serveur Securite 2566 mots 13 min de lecture

Configurer un pare-feu applicatif (WAF) avec Nginx

Lundi matin, j'arrive au boulot, un client me ping en urgence : son WordPress repond en 8 secondes au lieu de 200 ms et le CPU du VPS est a 100%. Je regarde les logs Nginx, et la, c'est la fete : 187 requetes par seconde sur /wp-login.php depuis une vingtaine d'IP differentes, soit du brute force distribue, soit un bot qui essaie son dictionnaire. iptables a beau filtrer le port 22, sur le 443 il laisse passer tout le monde parce qu'il ne lit pas le HTTP. C'est exactement la qu'un pare-feu applicatif (WAF) prend tout son sens : il comprend les URLs, les en-tetes, les payloads, et il peut decider qu'une IP qui tape /wp-login.php 50 fois en 10 secondes, ce n'est pas un humain.

Je te montre comment transformer un Nginx 1.24 sur Debian 12 en WAF efficace : rate limiting, geo blocking, filtres signatures, et l'integration de ModSecurity avec les regles OWASP Core Rule Set. Pas de magie, que de la conf concrete que tu peux copier-coller en production apres relecture.

Pourquoi un WAF en plus du pare-feu reseau ?

iptables, UFW, nftables, le pare-feu d'IONOS... ils travaillent en couche 3/4 (IP et TCP/UDP). Ils ne savent rien de HTTP. Un WAF, lui, lit la requete applicative complete : methode, URL, headers, body, cookies. Il peut donc detecter :

Nginx integre nativement de quoi faire un WAF basique tres correct. Pour aller plus loin on lui colle ModSecurity. Les deux ensemble, c'est ceinture et bretelles. Sur les serveurs que j'admin, cette combinaison divise les logs d'erreur par cinq des la premiere semaine : tout le bruit de scan automatique disparait, et il ne reste que les vraies tentatives qui meritent qu'on s'y attarde.

Niveaux d'attaque et reponses adaptees

Une attaque classique se decompose en couches : reconnaissance (scan de ports, fingerprinting), exploration (enumeration des chemins WordPress, plugins, fichiers exposes), exploitation (injection, RCE, brute force). iptables stoppe la premiere couche en bloquant des CIDR entiers. Le rate limiting Nginx stoppe la deuxieme. ModSecurity et les patterns matching stoppent la troisieme. C'est pour ca qu'aucune brique seule ne suffit.

Cas reel : un WordPress qui prenait 30 000 hits/jour de bots

Un client e-commerce sous WordPress + WooCommerce, quatre semaines apres un changement de DNS, voit son wp-login.php se faire marteler par 30 000 requetes par jour reparties sur 800 IP differentes. Les sessions PHP-FPM saturent et les vrais clients ont des temps de reponse degrades. Sans WAF, la seule reponse possible c'est un fail2ban tres agressif qui bannit aussi des clients legitimes derriere des CGN d'operateurs mobiles. Avec rate limiting Nginx + ModSecurity en mode On + Fail2ban qui lit les blocs ModSecurity : 95% des hits sont desormais bloques au niveau Nginx sans toucher PHP, le CPU PHP-FPM redescend de 80% a 12% en moyenne, et les clients ne voient plus aucun ralentissement.

Rate limiting : la base, et ca evite 80% du bruit

La premiere brique. Limiter le nombre de requetes par client par unite de temps.

Definir les zones

Dans /etc/nginx/nginx.conf, bloc http {} :

http {
    limit_req_zone $binary_remote_addr zone=global:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
}

Les 10m reservent 10 Mo de memoire partagee, soit environ 160 000 IP en suivi simultane. Largement assez pour un site moyen.

Appliquer dans les vhosts

server {
    listen 443 ssl http2;
    server_name monsite.fr;

    limit_req zone=global burst=20 nodelay;
    limit_conn conn_limit 10;

    location = /wp-login.php {
        limit_req zone=login burst=3 nodelay;
        limit_req_status 429;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location /wp-admin/admin-ajax.php {
        limit_req zone=api burst=10 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}

burst autorise un depassement temporaire en file d'attente. nodelay traite ces requetes immediatement au lieu de les ralentir. limit_req_status 429 renvoie un code HTTP 429 (Too Many Requests) plutot que le 503 par defaut, ce qui est plus correct semantiquement et plus agreable cote logs.

Resultat sur mon client de lundi matin : passage de 187 req/s sur wp-login a 1 req/s par IP, charge CPU divisee par 20, plus aucun ralentissement legitime.

Affiner par type de client

Tous les clients ne se valent pas. Un crawler Google legitime ne doit pas etre filtre, un cron interne non plus. La cle $binary_remote_addr ne suffit pas si tu veux des regles plus fines. Tu peux combiner avec une map qui lit le User-Agent et bypass les bots de moteurs de recherche :

map $http_user_agent $is_bot {
    default 0;
    "~*Googlebot" 1;
    "~*Bingbot" 1;
    "~*DuckDuckBot" 1;
}

map $is_bot $limit_key {
    0 $binary_remote_addr;
    1 "";
}

limit_req_zone $limit_key zone=global:10m rate=10r/s;

Quand $limit_key est vide, Nginx ne compte pas la requete. C'est une whitelist propre, sans CIDR a maintenir, qui survit aux changements d'IP des bots.

Geo blocking : reduire la surface d'attaque

Si ton site cible la France, le trafic depuis la Coree du Nord ou la Russie n'est probablement pas legitime. Ca ne remplace pas un WAF, mais ca elimine du bruit. Avec GeoIP2 :

apt install libnginx-mod-http-geoip2 -y
mkdir -p /usr/share/GeoIP
wget -O /tmp/dbip.mmdb.gz https://download.db-ip.com/free/dbip-country-lite-2026-03.mmdb.gz
gunzip -c /tmp/dbip.mmdb.gz > /usr/share/GeoIP/GeoLite2-Country.mmdb

Dans /etc/nginx/conf.d/geo.conf :

geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
    auto_reload 24h;
    $geoip2_country_code country iso_code;
}

map $geoip2_country_code $blocked_country {
    default 0;
    CN 1;
    RU 1;
    KP 1;
    IR 1;
}

Dans le vhost :

server {
    if ($blocked_country) {
        return 444;
    }
}

Le code 444 (specifique a Nginx) ferme la connexion sans reponse, ce qui est plus economique qu'un 403 quand l'IP est clairement malveillante. Attention quand meme : VPN, Tor et CDN faussent la geolocalisation. C'est un filtre, pas une frontiere.

Pour mettre a jour la base GeoIP automatiquement, j'ajoute un cron :

cat > /etc/cron.weekly/update-geoip <<'EOF'
#!/bin/bash
DATE=$(date +%Y-%m)
wget -q -O /tmp/dbip.mmdb.gz "https://download.db-ip.com/free/dbip-country-lite-${DATE}.mmdb.gz"
if [ -s /tmp/dbip.mmdb.gz ]; then
    gunzip -c /tmp/dbip.mmdb.gz > /usr/share/GeoIP/GeoLite2-Country.mmdb
    nginx -t && systemctl reload nginx
fi
EOF
chmod +x /etc/cron.weekly/update-geoip

La base est mise a jour mensuellement par db-ip, donc une execution hebdomadaire suffit largement.

Filtrage par patterns : bloquer le bruit signature

Les bots qui scannent les fichiers .env, les vieux backups SQL ou les .git/config, c'est constant. Bloque tout ca proprement :

server {
    location ~* /\.(?:git|env|svn|hg|DS_Store|htaccess) {
        deny all;
        return 403;
    }

    location ~* \.(?:env|bak|backup|sql|log|swp|tar|gz|zip|orig|old)$ {
        return 403;
    }

    location ~* /(?:wp-config\.php|configuration\.php|local-config\.php) {
        deny all;
    }

    if ($http_user_agent ~* (sqlmap|nikto|nmap|masscan|zgrab|wpscan|acunetix|fimap|whatweb)) {
        return 444;
    }

    if ($request_method !~ ^(GET|POST|HEAD|PUT|DELETE|OPTIONS)$) {
        return 405;
    }

    client_max_body_size 25M;
    client_body_buffer_size 128k;
    large_client_header_buffers 4 8k;
}

Blocage des methodes exotiques (TRACE, TRACK, DEBUG) qui ne devraient jamais arriver chez toi en prod, et limitation stricte de la taille des bodies pour eviter les uploads abusifs.

ModSecurity : le WAF complet

Pour une protection avancee, ModSecurity + OWASP Core Rule Set. C'est ce que la plupart des CDN proposent en option payante, sauf que la c'est gratuit et chez toi.

Installation

apt install libmodsecurity3 libnginx-mod-http-modsecurity -y
mkdir -p /etc/nginx/modsecurity
cp /etc/modsecurity/modsecurity.conf-recommended /etc/nginx/modsecurity/modsecurity.conf

Edite /etc/nginx/modsecurity/modsecurity.conf :

SecRuleEngine DetectionOnly
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsec_audit.log

On commence en DetectionOnly, c'est la regle d'or pour eviter de bloquer ses propres utilisateurs des le premier deploiement.

Installer OWASP CRS

cd /etc/nginx/modsecurity
git clone https://github.com/coreruleset/coreruleset.git crs
cp crs/crs-setup.conf.example crs/crs-setup.conf

Cree un fichier d'inclusion /etc/nginx/modsecurity/main.conf :

Include /etc/nginx/modsecurity/modsecurity.conf
Include /etc/nginx/modsecurity/crs/crs-setup.conf
Include /etc/nginx/modsecurity/crs/rules/*.conf

Activer dans Nginx

server {
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsecurity/main.conf;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }
}

Teste la conf et reload :

nginx -t && systemctl reload nginx

Laisse tourner 3 a 7 jours en DetectionOnly, analyse /var/log/modsec_audit.log, ajoute les exceptions pour ton CMS (WordPress, PrestaShop sont connus pour declencher des regles CRS sur les payloads admin), puis passe en SecRuleEngine On.

Exceptions classiques pour WordPress

Dans /etc/nginx/modsecurity/crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf :

SecRule REQUEST_URI "@beginsWith /wp-admin/admin-ajax.php" \
    "id:1000,phase:1,pass,nolog,\
    ctl:ruleRemoveById=920273,\
    ctl:ruleRemoveById=941100"

SecRule REQUEST_URI "@beginsWith /wp-json/" \
    "id:1001,phase:1,pass,nolog,\
    ctl:ruleRemoveTargetByTag=attack-rce;ARGS:content"

Niveaux de paranoia CRS

Le CRS expose un parametre tx.paranoia_level de 1 a 4. Le niveau 1 (defaut) bloque les attaques evidentes avec quasi zero faux positif. Le niveau 2 bloque des patterns plus subtils mais te demande quelques exclusions. Le niveau 3 et 4 sont reserves a des contextes ultra-sensibles (banques, sante) et generent beaucoup de faux positifs sur du contenu utilisateur normal. Pour la majorite de mes clients je reste au niveau 1, parfois je monte a 2 sur les sites e-commerce ou la stack est connue. Modifie dans crs-setup.conf :

SecAction \
  "id:900000,\
   phase:1,\
   nolog,\
   pass,\
   t:none,\
   setvar:tx.paranoia_level=1"

Tester son WAF

for i in $(seq 1 50); do
  curl -s -o /dev/null -w "%{http_code} " https://monsite.fr/wp-login.php
done; echo

Tu dois voir une serie de 200 puis des 429 quand tu depasses la limite.

Test injection SQL :

curl -i "https://monsite.fr/?id=1%27%20OR%201%3D1--"

Doit renvoyer 403 si ModSecurity est en On.

Test user-agent suspect :

curl -i -A "sqlmap/1.7" https://monsite.fr/

Doit renvoyer 444 (connexion fermee).

Logs en temps reel :

tail -f /var/log/nginx/error.log /var/log/modsec_audit.log

Benchmark de l'overhead

Un WAF n'est pas gratuit en CPU. Sur un VPS IONOS 2 vCPU avec un Nginx servant 200 req/s d'une page WordPress mise en cache :

Sans ModSecurity      : 12% CPU, latence p95 38ms
Avec ModSecurity CRS  : 18% CPU, latence p95 52ms

L'overhead est de l'ordre de 30 a 50% sur le composant Nginx, mais reste invisible cote utilisateur tant que le serveur n'est pas a la limite. Si jamais ton CPU sature sous charge, regarde du cote de SecRequestBodyAccess Off pour les routes ou tu n'as pas besoin d'inspecter le body (assets statiques notamment).

Integration avec Fail2ban et CrowdSec

Le WAF detecte et repond ponctuellement, mais ne bannit pas a long terme. Couple-le a Fail2ban qui lit les logs Nginx et bannit les IP recidivistes au niveau iptables. Crontab dediee dans /etc/fail2ban/filter.d/nginx-modsec.conf :

[Definition]
failregex = ^.*\[client <HOST>\].*ModSecurity:.*$
ignoreregex =

Et dans /etc/fail2ban/jail.local :

[nginx-modsec]
enabled = true
port = http,https
filter = nginx-modsec
logpath = /var/log/nginx/error.log
maxretry = 5
findtime = 600
bantime = 3600

CrowdSec va plus loin : il partage les IP malveillantes avec une communaute mondiale, donc tu beneficies des bans detectes ailleurs avant meme d'etre attaque. Sur mes serveurs c'est ce duo Nginx + ModSecurity + CrowdSec qui tourne, et ca a transforme le quotidien : 90% des tentatives sont bloquees au niveau reseau avant meme d'arriver a Nginx.

Comparatif rapide WAF Nginx vs alternatives

Quand on me demande pourquoi ne pas plutot prendre Cloudflare WAF, AWS WAF, ou un appliance dedie F5/Imperva, voila ma grille de lecture :

Sur les serveurs IONOS de mes clients, je tourne avec un Cloudflare gratuit en frontal pour absorber les pics et faire du caching edge, et derriere un Nginx + ModSecurity + CrowdSec sur le VPS pour la deuxieme couche. Les deux ne se marchent pas dessus tant que tu configures correctement set_real_ip_from sur les CIDR Cloudflare, sinon tu rate-limites le proxy au lieu du client final.

Erreurs courantes et leur fix

Pour aller plus loin

Un WAF est une couche parmi d'autres. Empile-les pour une defense en profondeur :

Ton serveur, ta forteresse applicative

Un WAF Nginx bien configure, c'est trois heures de travail un samedi apres-midi et 90% du bruit malveillant qui disparait de tes logs. Le rate limiting elimine le brute force, le filtrage de patterns degage les bots paresseux, et ModSecurity en CRS gere les attaques applicatives serieuses. Combine ca avec Fail2ban ou CrowdSec qui consomme tes logs Nginx pour bannir les recidivistes au niveau iptables, et tu as une infrastructure dont la surface d'attaque est divisee par dix. Reste ensuite a maintenir tout ca a jour, surveiller les regles CRS qui evoluent, et profiter du calme.

EDIT 2026 : depuis CRS 4.0, la structure des regles a ete revue avec une separation plus nette entre regles generiques et regles dediees. Si tu mets a jour, retesta tes exclusions WordPress, certains identifiants ont change. Et si tu trouves la conf manuelle penible, regarde du cote du projet bunkerweb qui empile Nginx + ModSecurity + CrowdSec dans un Docker pre-configure : c'est plus opaque, mais ca depanne pour les setups rapides.

# Articles similaires

Sur les memes sujets et plus loin