Je vous l’avais teasé dans mon test de Maintenant (« un Waf Open Source, dont je vous parlerai très bientôt »), eh bien le voilà ! BunkerWeb, c’est un reverse proxy basé sur nginx, avec un WAF intégré (ModSecurity et l’OWASP Core Rule Set), de l’antibot, du bannissement automatique, une interface web, le tout open source et développé en France par Bunkerity… sur le papier, c’est pile ce qu’il nous faut quand on expose des applis « basique dont la sécurité nous est déléguée.

Sauf qu’entre lire une doc et savoir ce que l’outil bloque VRAIMENT, il y a un monde. Donc plutôt qu’un énième « j’ai lancé le docker compose et ça marche », j’ai monté un vrai PoC (ce qui m’a permis aussi de réviser mes fondamentaux ansible avec Jarvis) : trois VM sur mon Proxmox, des applis volontairement trouées derrière, un poste « attaquant » et un script qui rejoue les attaques avec et sans WAF. Le tout déployé de bout en bout par Ansible, histoire de pouvoir tout casser et tout reconstruire en quelques minutes (et croyez-moi, j’ai beaucoup cassé).

BunkerWeb, c’est quoi exactement ?

BunkerWeb, c’est un nginx « durci par défaut », piloté par une grosse liste de réglages (plusieurs centaines, tous documentés) plutôt que par des fichiers de conf nginx écrits à la main. On lui dit quel nom de site sert quel backend, on active ou non chaque protection, et il génère la configuration nginx qui va bien.

Côté protections, on trouve dans la version communautaire : ModSecurity avec le CRS 4 ; des en-têtes de sécurité posés automatiquement (HSTS, CSP, X-Frame-Options…) ; l’antibot (challenge JavaScript, cookie, captcha maison etc …) ; la limitation de débit et de connexions ; le « bad behavior », qui bannit une IP qui accumule trop d’erreurs 4xx ; des blacklists(IP, User-Agent, URI, listes communautaires, DNSBL, pays) ; HTTP/3, compression gzip et brotli (compression plus forte que gzip, dont j’ai appris l’existence en lisant la doc Bunker 🙂 ). Il existe aussi une version PRO payante (anti DDoS, monitoring Prometheus, rapports…), que je n’ai pas testée ici.

Petit aparté pour ceux qui ne connaissent pas : le Core Rule Set de l’OWASP (le CRS pour les intimes, appris aussi en lisant le background de BunkerWeb ^^), c’est un jeu de règles open source pour ModSecurity (et son cousin Coraza), qui repère de façon générique les grands classiques des attaques web, injections SQL, scripts injectés dans les pages, inclusion de fichiers, injection de commandes, scanners, requêtes HTTP bancales… sans rien savoir de votre appli. Le principe est simple : chaque règle qui se déclenche ajoute des points à la requête, et au-delà d’un seuil, elle est bloquée, comme pour les courriels spam en somme. Un « niveau de paranoïa » (de 1 à 4) permet ensuite de régler le curseur entre « je laisse passer un peu plus » et « je bloque tout ce qui bouge, faux positifs compris ». C’est cette brique que BunkerWeb embarque par défaut, en version 4 dans mon PoC.

L’architecture depuis la 1.6 est découpée en briques :

– bunkerweb : l’instance nginx elle-même, celle qui reçoit le trafic sur 80/443 ;
– bw-scheduler : le cerveau, qui lit la configuration, génère la conf nginx, la pousse aux instances par une API interne et gère les tâches planifiées (renouvellement des certificats, téléchargement des listes noires…) ;
– bw-ui : l’interface web d’administration ;
– une base de données (MariaDB dans mon cas, SQLite par défaut) qui stocke la configuration et l’état ;
– Redis, optionnel, pour partager bans, sessions et compteurs entre plusieurs instances.

Il existe plusieurs intégrations : Docker, Docker « autoconf » (configuration par labels sur les conteneurs, à la Traefik), Swarm, Kubernetes, ou paquets Linux. J’y reviens plus bas, parce que le choix a des conséquences.

L’architecture du PoC

Trois VM Debian 13 sur Proxmox VE 9, sur un VLAN de labo en DHCP :

VMRôleConteneurs
bw-wafpoint d’entrée unique, 80/443 (TCP, et UDP pour HTTP/3)bunkerweb, bw-scheduler, bw-ui, bw-db (MariaDB 11), bw-redis
bw-appsles sites de test, joignables UNIQUEMENT depuis le WAFpoc-www (nginx), poc-shop (OWASP Juice Shop), poc-api (go-httpbin)
bw-clientle poste « attaquant »curl, sqlmap, nmap, ab et le script de tests

Si j’ai séparé le WAF et les applis sur deux VM, c’est parce qu’en vrai, on aura un WAF en DMZ et des backends ailleurs. Et ça oblige à se poser une question qu’on oublie souvent (ou pas, mais ça va mieux en le disant) : qu’est-ce qui empêche un attaquant de contourner le WAF en tapant directement sur l’appli ? Ici, un filtrage iptables dans la chaîne DOCKER-USER sur bwapps, qui n’autorise que l’IP du WAF à joindre les ports publiés des applis :

Petite subtilité au passage : Docker réécrit le port de destination (DNAT) avant que le paquet n’arrive dans DOCKER-USER, donc un --dport 8081 classique ne matche rien. Il faut filtrer sur le port d’origine avec --ctorigdstport. Le genre de détail qui fait perdre une heure si on ne le sait pas ^^. Hein ? quoi, mais non j’ai pas perdu cette heure …

Une variante tout-en-un (une seule VM) existe aussi dans le dépôt, pour ceux qui veulent juste jouer : le mode est détecté tout seul, et dans ce cas BunkerWeb joint les applis par un réseau Docker interne sans publier aucun port applicatif.

Trois sites de test, trois terrains de jeu

Une page « Hello World » derrière un WAF, ça ne montre rien. J’ai donc pris trois applis, et chacune sert à faire réagir un groupe de protections. La pour le coup, je me suis fait aider par Jarvis, mon pote en silicone, car c’était la galère de se créer un jeu de test.

"www" est une vitrine nginx statique avec un formulaire de contact. C’est là que j’ai mis l’antibot en mode challenge JavaScript : un navigateur passe sans s’en rendre compte, un curl reste coincé sur /challenge. On y vérifie aussi les en-têtes de sécurité et la redirection HTTPS.

shop est OWASP Juice Shop, l’appli volontairement truffée de failles qu’on utilise en formation sécu. C’est le terrain de jeu de ModSecurity et du CRS justement (injections SQL, XSS, traversée de répertoires). Elle utilise aussi des websockets (socket.io), ce qui permet de vérifier au passage que le WAF ne casse pas l’appli.

api est go-httpbin, une API qui renvoie ce qu’on lui envoie. Idéal pour tester les contraintes protocolaires (méthodes autorisées, taille maximale du corps), la limitation de débit, le bad behavior et une règle ModSecurity écrite à la main. Et comme elle renvoie les en-têtes reçus, on voit tout de suite si le backend reçoit bien le X-Forwarded-For.

Et enfin bwui, l’interface de BunkerWeb, publiée… derrière BunkerWeb, évidemment. Là il faudrait y passer des heures mais ça mérite un vrai webinar dont je suis incapable, car je ne connais ni complètement l’outil ni toutes les mécaniques nécessaires a la justification d’un WAF. Cependant, un, c’est super utile ! et deux, que ça ne vous empêche pas de creuser de votre côté, vBlog est la aussi pour vous donner envie, pas forcément pour vos mâcher le travail 😉

Intégration Docker, pas d’autoconf ?

L’autoconf est très séduisant quand on vient de Traefik : on pose des labels sur les conteneurs et BunkerWeb s’adapte. Sauf qu’il ne voit que les conteneurs du même hôte Docker. Avec des backends sur une autre VM, ça ne marche pas.

J’ai donc pris l’intégration Docker « classique » : toute la configuration est portée par les variables d’environnement du bw-scheduler (le coeur du fonctionnement de BunkerWeb). Avec MULTISITE=yes, chaque réglage peut être préfixé par le nom du site. Un extrait du compose généré (The secrets have been changed to protect the innocents …) :

Le scheduler parle à l’instance bunkerweb par une API interne sur un réseau Docker dédié (bw-universe), et seul ce réseau est autorisé (API_WHITELIST_IP). Les réglages posés par variables apparaissent dans l’UI en lecture seule ; ce qu’on modifie dans l’UI est stocké en base et s’ajoute par-dessus. C’est pratique pour tester, mais en production je garderais la conf dans le code et l’UI pour la consultation.

Côté Ansible, on ne touche pas au compose : tout est décrit dans un dictionnaire par site, et le template fait le reste. Ajouter une règle ModSecurity maison, c’est trois lignes :

Redis est activé dès le départ : bans et compteurs sont partagés, ce qui prépare le jour où on veut une deuxième instance bunkerweb derrière un VIP keepalived, sans rien changer au reste.

Le déploiement : Ansible de bout en bout

Le dépôt fait tout, de la VM jusqu’aux tests, en cinq playbooks enchaînés par un make deploy : création des VM sur Proxmox ; socle Debian et Docker CE ; stack des applis et filtrage ; stack BunkerWeb (avec attente que l’API réponde au travers du WAF) ; poste client et script de tests.

Pour Proxmox, j’ai voulu faire propre et ne pas tout lancer en root@pam. Le SSH root ne sert qu’une seule fois, pour fabriquer le modèle de VM. Ensuite tout passe par l’API avec un compte dédié bwpoc@pve, un jeton, et un rôle taillé au plus juste, limité à un pool bwpoc, au stockage utilisé et au seul VLAN du labo (merci Jarvis, encore une fois) :

Petite note PVE 9 : la lecture des infos de l’agent invité passe par le nouveau privilège VM.GuestAgent.Audit (sur PVE 8, c’était VM.Monitor). Et l’ACL réseau sur /sdn/zones/localnetwork/vmbr0/200 permet de n’autoriser que le VLAN 200 sur le pont, ce qui est bien pratique (bidouille apprise sur le tas il y a quelques semaines)

Les VM sont en DHCP, pas d’IP fixe dans l’inventaire. Le playbook démarre les clones, attend l’agent QEMU, lui demande l’IP obtenue, l’écrit dans inventory/host_vars/<vm>/dhcp.yml et recharge l’inventaire. Les playbooks suivants utilisent ces IP. Seul conseil : réservez les baux DHCP (au moins pour un mois, a voir en fonction de la durée de votre PoC) des trois VM, parce que l’IP du WAF sert au filtrage iptables côté applis.

Du premier coup ?

Je ne vais pas vous mentir, ça n’est pas passé du premier coup.

Première difficulté, le modèle sans agent QEMU. Mon modèle cloud-init Debian 13 existant n’avait pas qemu-guest-agent. Sans agent, impossible de lire l’IP DHCP par l’API. J’ai donc fabriqué un modèle dédié (avec un apt install qemu-guest-agent intégré …)

Deuxième difficulté, et pas la moins drôle : au premier déploiement, les trois VM obtiennent… la même IP DHCP. Le coupable : le machine-id hérité du modèle, dont le client DHCP dérive son identifiant (gna gna gna … j’aurais du le savoir …). Même machine-id, même identifiant, même bail. La parade : un cloud-init clean --machine-id dans le modèle, et le playbook régénère le machine-id de chaque clone au premier démarrage par l’agent QEMU (API /agent/exec, sans SSH), avant de redemander un bail.

Troisième difficulté, le reload silencieux de BunkerWeb, hein, quoi ?. J’ai ajouté une règle ModSecurity chaînée (deux lignes SecRule liées par chain) mal formée sur la deuxième ligne. Résultat : le reload nginx échoue, et BunkerWeb… ne dit rien de visible, ce saligaud, il reste sur l’ancienne configuration (son mécanisme de failover). Tout a l’air de tourner, sauf que la nouvelle règle n’existe pas. Dans mon cas, effet de bord rigolo, mon client de test s’est fait bannir en boucle par l’ancienne conf. Moralité, après chaque changement de conf, regardez les logs du scheduler. Et si vous vous bannissez vous-même

Et pour finir, le coup du DNAT dans DOCKER-USER dont je parlais plus haut (--ctorigdstport et pas --dport, je le répète parce que je me suis fait avoir ^^).

Les tests : 26 sur 26

Le dépôt embarque un script bwpoc-tests qui rejoue une série de requêtes avec curl --resolve (donc pas besoin de DNS) et compare le code HTTP obtenu à celui attendu.

Je rappelle que cette partie là a été totalement réalisée avec l’aide de Jarvis … et merci encore à lui (oui je sais je parle à des IA … dans le doute, on ne sait jamais, Skynet n’est as si loin selon certains …)

Sortie réelle, lancée depuis bw-client :

Le bannissement par bad behavior est par site : mon client est banni de api mais continue d’accéder à shop, et je trouve ça plutôt logique (une appli bavarde en 404 ne pénalise pas les autres). La limitation de débit, elle, est volontairement sévère (5 requêtes/s sur l’API, et sur une rafale de 40 il n’en passe qu’une !), à ajuster selon l’appli bien sûr. Et les derniers tests bannissent volontairement le client pendant 5 minutes ; j’ai raccourci le BAD_BEHAVIOR_BAN_TIME (1 jour par défaut !) pour pouvoir rejouer.

Avec et sans WAF

Le test le plus parlant, c’est de rejouer les mêmes attaques en direct sur les applis, en ouvrant temporairement le filtrage au client (apps_direct_access_sources dans l’inventaire, puis bwpoc-tests --direct). La SQLi de Juice Shop renvoie alors gentiment la liste complète des produits en 200 au lieu d’un 403, et le PUT passe sans broncher. Rien de surprenant, mais le voir noir sur blanc dans sa console, ça parle plus qu’une plaquette commerciale.

Aller plus loin à la main

Le script ne fait qu’effleurer le sujet, et voici ce que je vous conseille de tester à la main :

– sqlmap contre Juice Shop avec son User-Agent par défaut : bloqué tout de suite par la liste communautaire. Avec --random-agent, c’est le CRS qui prend le relais, et c’est là qu’on voit sa vraie valeur ;
– l’antibot en mode captcha ou cookie sur www, à tester dans un navigateur ;
– les faux positifs : naviguez normalement dans Juice Shop (connexion, panier, avis). Ce que le CRS bloque à tort remonte dans l’UI (menu Reports) avec l’ID de la règle, qu’on exclut par un SecRuleRemoveById dans un custom_confs de type MODSEC_CRS ;
– le mode MODSECURITY_SEC_RULE_ENGINE: "DetectionOnly" sur un site, pour observer avant de bloquer. Clairement, c’est comme ça que je démarrerais en production ;
– les journaux : docker logs -f bunkerweb, et l’audit ModSecurity dans /var/log/bunkerweb/modsec_audit.log du conteneur.

Et pour l’exposer ?

Pour le rendre accessible de l’extérieur, j’ai simplement mis le WAF derrière mon Traefik exposant *.vlab.bzh et s’occupe dynamiquement des certifs Let’s Encrypt. Le seul point à retenir côté BunkerWeb : déclarer l’IP du reverse proxy en amont (USE_REAL_IP, REAL_IP_FROM, REAL_IP_HEADER: X-Forwarded-For), sinon BunkerWeb voit toutes les requêtes venir de Traefik… et le premier bannissement bannit tout le monde ^^. L’interface d’admin, elle, est réservée aux IP privées par une petite règle ModSecurity par site. Le dépôt sait aussi faire du Let’s Encrypt directement par BunkerWeb si vous n’avez pas de proxy devant.

Les limites du PoC

Pour être honnête jusqu’au bout : une seule instance BunkerWeb, donc pas de haute dispo ; certificats auto-signés en interne ; les fonctions PRO ne sont pas couvertes ; et le géoblocage comme les DNSBL ne se testent pas proprement depuis des IP privées.

Alors, verdict ?

Franchement, je suis impressionné par le rapport effort/résultat. Avec une poignée de variables, on a un reverse proxy correctement durci, le CRS 4 actif partout, de l’antibot, du bannissement, et une UI qui permet enfin de voir ce qui se passe (ce qui n’a jamais été le point fort d’un ModSecurity monté « à la main », on ne va pas se mentir). La doc est riche, les réglages sont nombreux mais cohérents, et l’intégration Docker se pilote très bien par Ansible, et avec l’aide d’une IA compétente ça roule tout seul.

Il y a quand même des points d’attention : comme tout WAF, le travail sur les faux positifs qui ne se fait pas tout seul. On ne le pose pas pour l’oublier ensuite (qui ne fait pas ça par manque de temps et des priorités qui changent tout le temps). Mais pour des besoins « classiques », je le vois tout à fait remplacer des WAF propriétaires, et en plus c’est français, ce qui ne gâche rien (souveraineté, gna gna gna … ^^).

Si certains d’entre vous l’utilisent déjà en production, je suis TRES preneur de vos retours en commentaire notamment pour voir si les options PROs sont super utiles ou non). D’ailleurs je pense très sérieusement l’adopter pour le fontal WAF de vBlog.io 😉

Références :
– Test de Maintenant, l’outil de Benjamin (callback hook ou je vous teasait déjà BunkerWeb)
– BunkerWeb : documentation
– BunkerWeb sur GitHub : fichiers d’intégration officiels
– OWASP : Core Rule Set
– OWASP CRS : documentation
– OWASP : projet ModSecurity Core Rule Set
– OWASP : Juice Shop
– go-httpbin : dépôt GitHub
– Le PoC : dépôt Ansible + docker-compose sur mon Gitea

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *