# PoC BunkerWeb sur Proxmox Un environnement de test complet pour [BunkerWeb](https://www.bunkerweb.io/), le reverse proxy WAF open source basé sur nginx, déployé en docker-compose sur des VM Proxmox, de bout en bout par Ansible. L'objectif : voir concrètement ce que BunkerWeb bloque, laisse passer et journalise, sur des applis qui s'y prêtent, puis rejouer les mêmes attaques sans WAF pour comparer. ![Architecture du PoC](docs/architecture.png) ## Ce que contient le PoC | VM | Rôle | Conteneurs | |---|---|---| | `bw-waf` | Point d'entrée unique, ports 80/443 (TCP + UDP pour HTTP/3) | `bunkerweb`, `bw-scheduler`, `bw-ui`, `bw-db` (MariaDB 11), `bw-redis` | | `bw-apps` | Sites de test, joignables **uniquement** depuis le WAF (iptables `DOCKER-USER`) | `poc-www` (nginx), `poc-shop` (OWASP Juice Shop), `poc-api` (go-httpbin) | | `bw-client` | Poste "attaquant" avec les outils et le script de tests | `curl`, `sqlmap`, `nmap`, `ab`, `bwpoc-tests` | Versions épinglées dans [`inventory/group_vars/all.yml`](inventory/group_vars/all.yml) : BunkerWeb **1.6.17**, Juice Shop v20.3.0, go-httpbin 2.26.0, nginx 1.30, Debian 13 (trixie) genericcloud. Une variante **tout-en-un** (une seule VM) est fournie : [`inventory/single.yml`](inventory/single.yml). Le mode est détecté automatiquement : si WAF et applis sont le même hôte, BunkerWeb joint les applis par le réseau Docker `bw-services` et aucun port applicatif n'est publié. ### Les sites de test Chaque site met en avant un groupe de fonctionnalités : | Site | Appli | Ce qu'on y teste | |---|---|---| | `www.` | vitrine nginx statique (page d'accueil + formulaire de contact) | antibot JavaScript, en-têtes de sécurité, redirection HTTPS, XSS via formulaire dans le navigateur | | `shop.` | [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/), appli volontairement vulnérable | ModSecurity + OWASP CRS 4 (SQLi, XSS, traversée), websockets, User-Agents malveillants | | `api.` | [go-httpbin](https://github.com/mccutchen/go-httpbin), API qui renvoie ce qu'elle reçoit | limitation de débit, méthodes autorisées, taille max du corps, bad behavior et bannissement, règle ModSecurity maison, compression, `X-Forwarded-For` | | `bwui.` | interface web de BunkerWeb | rapports d'attaques, bannissements, réglages, journaux | Le domaine par défaut est `bwpoc.internal` (TLD réservé par l'ICANN pour l'usage privé). Les certificats sont auto-signés par BunkerWeb, site par site. ## Prérequis - Un nœud Proxmox VE 8 ou 9 (ici `lancelot`, 172.16.16.7) avec accès Internet (image Debian, images Docker, listes noires communautaires). - Un accès SSH **root** au nœud, utilisé **une seule fois** pour créer le modèle de VM et le pool `bwpoc`. Tout le reste passe par l'API avec un compte dédié (voir plus bas). - Un réseau de VM avec DHCP : ici le VLAN 200 "LAB", qui fournit IP, passerelle et DNS. Le poste Ansible doit pouvoir joindre ce VLAN en SSH. - Sur le poste Ansible : `ansible-core` 2.17 ou plus (exigé par `community.proxmox`), `proxmoxer` et `requests` en Python, une paire de clés SSH. ```bash git clone git@gitea.vlab.bzh:vLab-BZH/bunkerwebpoc.git && cd bunkerwebpoc make deps # collections community.docker et community.proxmox, puis proxmoxer ``` ## Configuration Tout ce qui dépend de votre infra est marqué `A ADAPTER` : 1. [`inventory/hosts.yml`](inventory/hosts.yml) : nœud Proxmox, VMID des 3 VM, CPU, RAM, disque. Pas d'IP : elles viennent du DHCP. 2. [`inventory/group_vars/all.yml`](inventory/group_vars/all.yml) : domaine, nœud, stockage, pont et VLAN, compte API, clé SSH publique. 3. [`inventory/group_vars/bunkerweb.yml`](inventory/group_vars/bunkerweb.yml) : les réglages BunkerWeb, globaux et par site. C'est le fichier à modifier pour jouer avec les fonctionnalités. Les mots de passe (MariaDB, Redis, admin de l'UI) sont générés au premier lancement dans `credentials/` côté poste Ansible (ignoré par git). Pour les mettre sous `ansible-vault`, remplacez les `lookup('password')` de `bunkerweb.yml` par vos variables chiffrées. ## Compte Proxmox dédié Le clonage et le pilotage des VM passent par l'API avec l'utilisateur `bwpoc@pve` et un jeton, limité à un pool `bwpoc`. Par défaut, le PoC clone un modèle cloud-init Debian 13 existant (`pve_template_vmid`, 9001 sur lancelot : VLAN 200, agent QEMU), donc aucun SSH root n'est nécessaire. Avec `pve_template_create: true`, le modèle 9100 est fabriqué en SSH root (import d'une image disque par chemin, réservé à `root@pam`), une fois. Privilèges nécessaires, regroupés dans un rôle `BWPoC` : | Privilège | Pour quoi faire | |---|---| | `VM.Allocate`, `VM.Clone` | créer les clones dans le pool, les supprimer | | `VM.Config.CPU`, `VM.Config.Memory`, `VM.Config.Disk`, `VM.Config.Network`, `VM.Config.Cloudinit`, `VM.Config.Options`, `VM.Config.HWType`, `VM.Config.CDROM` | régler CPU, RAM, disque, cloud-init, démarrage auto, tags | | `VM.PowerMgmt` | démarrer et arrêter les VM | | `VM.Audit`, `Pool.Audit` | lire l'état des VM et du pool | | `VM.GuestAgent.Audit` (PVE 9) ou `VM.Monitor` (PVE 8) | lire l'IP DHCP par l'agent QEMU | Et les ACL, au plus juste : | Chemin | Rôle | Pourquoi | |---|---|---| | `/pool/bwpoc` | `BWPoC` | les VM du PoC (et le modèle 9100 s'il est fabriqué par le PoC) | | `/storage/zfs` | `PVEDatastoreUser` | allouer les disques des clones | | `/sdn/zones/localnetwork/vmbr0/200` | `PVESDNUser` | brancher les VM sur le VLAN 200 (et seulement lui) | | `/nodes/lancelot` | `PVEAuditor` | lecture du nœud par le module Ansible | En root sur `lancelot` (sur PVE 8, remplacer `VM.GuestAgent.Audit` par `VM.Monitor`) : ```bash pveum pool add bwpoc --comment "PoC BunkerWeb" pveum role add BWPoC --privs "VM.Allocate,VM.Clone,VM.Audit,VM.PowerMgmt,VM.Config.CPU,VM.Config.Memory,VM.Config.Disk,VM.Config.Network,VM.Config.Cloudinit,VM.Config.Options,VM.Config.HWType,VM.Config.CDROM,VM.GuestAgent.Audit,Pool.Audit" pveum user add bwpoc@pve --comment "PoC BunkerWeb (Ansible)" pveum user token add bwpoc@pve ansible --privsep 0 # affiche le secret une seule fois pveum acl modify /pool/bwpoc --users bwpoc@pve --roles BWPoC pveum acl modify /storage/zfs --users bwpoc@pve --roles PVEDatastoreUser pveum acl modify /sdn/zones/localnetwork/vmbr0/200 --users bwpoc@pve --roles PVESDNUser pveum acl modify /nodes/lancelot --users bwpoc@pve --roles PVEAuditor ``` Si le réseau "LAB" est un VNet SDN plutôt qu'un tag sur `vmbr0`, l'ACL réseau devient `/sdn/zones//LAB` et, dans `all.yml`, `pve_bridge: LAB` avec `pve_vlan_tag: ""`. Les secrets restent hors du dépôt, dans l'environnement. Au choix, le jeton : ```bash export PVE_TOKEN_SECRET='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ``` ou l'identifiant et le mot de passe du compte (le jeton est prioritaire s'il est défini) : ```bash export PROXMOX_CLAUDE='bwpoc@pve' export PROXMOX_PASSWORD='...' ``` ## Déploiement ```bash make deploy # = ansible-playbook playbooks/site.yml ``` | Étape | Playbook | Ce qu'elle fait | |---|---|---| | 1 | `00-proxmox.yml` | si `pve_template_create: true`, en root (une fois) : fabrique le modèle 9100 depuis l'image Debian 13 genericcloud. Puis par l'API : crée le pool `bwpoc`, clone le modèle (9001 par défaut), cloud-init en DHCP + clé SSH, agrandit les disques, démarre, régénère le machine-id par l'agent QEMU (sinon tous les clones obtiennent la même IP DHCP), lit l'IP obtenue par l'agent QEMU et l'écrit dans `inventory/host_vars//dhcp.yml` | | 2 | `10-base.yml` | paquets de base, agent QEMU, résolution locale des noms du PoC vers le WAF, Docker CE depuis le dépôt officiel | | 3 | `20-apps.yml` | stack des applis, filtrage `DOCKER-USER` (mode split) | | 4 | `30-bunkerweb.yml` | stack BunkerWeb, attente qu'`api` réponde au travers du WAF, écriture de `credentials/ACCES.txt` | | 5 | `40-client.yml` | outils d'attaque et script de tests sur `bw-client` | Vous avez déjà vos VM (Debian 12 ou 13, accès SSH avec sudo) ? Renseignez-les dans l'inventaire et sautez l'étape 1 : `make base apps waf client`. Pour relire les fichiers compose générés sans rien déployer : ```bash ansible-playbook playbooks/render.yml # écrit build/split/... ou build/single/... ``` Un exemple de rendu (secrets remplacés par `CHANGEME`) est versionné dans [`examples/split/`](examples/split/) (`bunkerweb.compose.yml`, `apps.compose.yml`, `bwpoc-firewall.sh`). ### Adresses IP et DHCP Les VM démarrent sur le VLAN 200 "LAB" en DHCP. Le modèle embarque l'agent QEMU : le playbook lit l'IP de chaque VM par l'API Proxmox, l'enregistre dans `inventory/host_vars//dhcp.yml` (ignoré par git) et recharge l'inventaire. Les playbooks suivants, et vos relances (`make waf`...), utilisent ces fichiers. Comme l'IP du WAF sert au filtrage `DOCKER-USER` et aux `/etc/hosts`, **réservez les baux DHCP** des trois VM (adresses MAC visibles dans Proxmox). Si une IP change quand même, relancez `make vms base apps waf client` : les fichiers `dhcp.yml` et les règles sont régénérés. ### Accéder aux sites depuis votre poste Les VM du PoC résolvent déjà les noms (bloc dans `/etc/hosts`). Sur votre poste, ajoutez la ligne suivante à `/etc/hosts`, ou déclarez les enregistrements dans votre DNS de labo : ``` www.bwpoc.internal shop.bwpoc.internal api.bwpoc.internal bwui.bwpoc.internal ``` Puis ouvrez `https://bwui.bwpoc.internal/` (certificat auto-signé à accepter). Identifiants : `make creds`. ## Comment BunkerWeb est configuré ici Intégration **Docker** officielle (pas l'autoconf) : toute la configuration est portée par les variables d'environnement du `bw-scheduler`, qui génère la configuration nginx et la pousse à l'instance `bunkerweb` par son API (réseau `bw-universe` 10.20.30.0/24, seul autorisé par `API_WHITELIST_IP`). Ce choix permet de protéger des backends situés sur une autre VM, ce que les labels de l'autoconf ne savent pas faire. `MULTISITE=yes` : chaque réglage peut être préfixé par le nom du site (`api.bwpoc.internal_LIMIT_REQ_RATE=5r/s`). Dans Ansible, il suffit de remplir le dictionnaire `bw_sites` : ```yaml bw_sites: api: upstream: api # clé de apps_ports, ou URL complète settings: ALLOWED_METHODS: "GET|POST|HEAD" MAX_CLIENT_SIZE: "1m" LIMIT_REQ_RATE: "5r/s" custom_confs: # devient api._CUSTOM_CONF_MODSEC_poc-rule MODSEC_poc-rule: >- SecRule ARGS:poc "@streq attack" "id:10001,phase:2,deny,status:403,log,msg:'PoC'" ``` Les réglages posés par variables d'environnement apparaissent dans l'UI en lecture seule (méthode `scheduler`). Les modifications faites dans l'UI sont stockées en base et s'ajoutent par-dessus. Redis est activé (`USE_REDIS=yes`) : bans, sessions et compteurs sont partagés, ce qui prépare l'ajout d'une seconde instance `bunkerweb` derrière un VIP (keepalived) sans changer le reste. ## Tester les fonctionnalités Depuis `bw-client` (ou n'importe quel poste avec `curl` et `jq`, le script utilise `--resolve` et n'a pas besoin de DNS) : ```bash make tests # depuis le poste Ansible ssh debian@ bwpoc-tests # ou directement sur le client ``` Le script enchaîne une vingtaine de contrôles et affiche `OK`, `KO` ou `INFO` : | Fonctionnalité | Réglage | Requête | Attendu | |---|---|---|---| | HTTPS forcé | `AUTO_REDIRECT_HTTP_TO_HTTPS` | `http://api/get` | 301 vers https | | Serveur par défaut fermé | `DISABLE_DEFAULT_SERVER` | Host inconnu | connexion fermée (444) | | En-têtes de sécurité | `STRICT_TRANSPORT_SECURITY`, `CONTENT_SECURITY_POLICY`... | `api/get` | en-têtes présents, `Server` masqué | | Proxy transparent | `USE_REVERSE_PROXY` | `api/headers` | le backend voit `X-Forwarded-For` | | Compression | `USE_GZIP`, `USE_BROTLI` | `api/html` | `Content-Encoding: br` ou `gzip` | | Antibot | `USE_ANTIBOT=javascript` | `www/` en curl | redirection vers `/challenge` | | WAF SQLi | ModSecurity + CRS 4 | `shop/rest/products/search?q=')) OR 1=1--` | 403 | | WAF XSS | idem | `shop/?q=` | 403 | | WAF traversée | idem | `shop/?file=../../../../etc/passwd` | 403 | | WAF injection de commande | idem | `api/get?cmd=;cat /etc/passwd` | 403 | | Règle maison | `CUSTOM_CONF_MODSEC` | `api/get?poc=attack` | 403 | | User-Agent maison | `BLACKLIST_USER_AGENT` | `-A PoC-EvilBot` | 403 | | User-Agent communautaire | `BLACKLIST_COMMUNITY_LISTS` | `-A sqlmap/1.8` | 403 (info : dépend du téléchargement de la liste) | | Méthodes | `ALLOWED_METHODS` | `PUT api/put` | 405 | | Taille du corps | `MAX_CLIENT_SIZE=1m` | POST de 2 Mo | 413 | | Limitation de débit | `LIMIT_REQ_RATE=5r/s` | 40 requêtes en parallèle | une partie en 429 | | Bad behavior | `BAD_BEHAVIOR_THRESHOLD=10` en 1 min | 12 × `/status/404` | IP bannie (403) sur `api` seulement | Les derniers tests **bannissent volontairement votre IP** du site `api` pendant 5 minutes (`bw_ban_time`). Pour lever le ban : menu *Bans* de l'UI, ou sur `bw-waf` : ```bash docker exec bw-scheduler bwcli unban ``` ### Avec et sans WAF Pour mesurer l'apport du WAF, autorisez le client à joindre les applis en direct, puis rejouez les attaques : ```yaml # inventory/group_vars/all.yml apps_direct_access_sources: [""] ``` ```bash make apps ssh debian@ bwpoc-tests --direct ``` La SQLi de Juice Shop renvoie alors la liste complète des produits (200) au lieu d'un 403, et le `PUT` passe. ### Aller plus loin à la main - **sqlmap** contre Juice Shop, d'abord avec son User-Agent par défaut (bloqué par la liste communautaire), puis avec `--random-agent` pour voir le CRS prendre le relais : `sqlmap -u "https://shop.bwpoc.internal/rest/products/search?q=test" --random-agent --batch` - **Antibot** dans le navigateur : passez `www` en `captcha` ou `cookie` dans `bunkerweb.yml`, relancez `make waf`. - **Faux positifs** : naviguez dans Juice Shop (connexion, panier, avis). Ce que le CRS bloque à tort apparaît dans l'UI (*Reports*) avec l'ID de règle ; on l'exclut par un `custom_confs` de type `MODSEC_CRS` (exemple : `SecRuleRemoveById 942100`). - **Mode détection seule** : `MODSECURITY_SEC_RULE_ENGINE: "DetectionOnly"` sur un site, pour observer avant de bloquer, comme on le ferait en production. - **Journaux** : `docker logs -f bunkerweb` sur `bw-waf`, et l'audit ModSecurity dans `/var/log/bunkerweb/modsec_audit.log` du conteneur. ## Exposer le PoC sur un domaine public (Let's Encrypt) Par défaut tout est en `bwpoc.internal` avec des certificats auto-signés. Pour un vrai domaine (exemple : `lab.naoned.net`) : 1. DNS : `www`, `shop`, `api` et `bwui` du domaine pointent sur l'IP publique NATée vers le WAF. 2. Pare-feu : 80 et 443 (TCP, plus 443/UDP pour HTTP/3) vers l'IP du WAF ; réservation DHCP sur la MAC du WAF pour que l'IP ne bouge pas. 3. Variables (inventaire ou `-e`) : ```yaml poc_domain: lab.naoned.net poc_tls: letsencrypt poc_letsencrypt_email: vous@example.org poc_allowed_sources: ["203.0.113.10"] # IP autorisées sur les sites de poc_restricted_sites (bwui par défaut) ``` 4. `make waf client tests`. La restriction par IP est une règle ModSecurity par site (id 10002) qui laisse passer le challenge ACME. Le client de test est toujours autorisé. Ajoutez `shop` à `poc_restricted_sites` pour ne pas exposer Juice Shop à tout Internet. ### Derrière un reverse proxy existant (Traefik) Si 80/443 sont déjà pris par un Traefik, il termine le TLS public (ses propres certificats Let's Encrypt) et relaie vers le WAF en HTTPS (certificat auto-signé du PoC) : garder `poc_tls: selfsigned` et déclarer son IP dans `poc_trusted_proxies` pour que BunkerWeb voie l'IP réelle des clients (sinon un bannissement viserait Traefik, donc tout le monde). Exemple de configuration dynamique Traefik (fichier) : ```yaml http: routers: bwpoc: rule: "Host(`www.lab.naoned.net`) || Host(`shop.lab.naoned.net`) || Host(`api.lab.naoned.net`) || Host(`bwui.lab.naoned.net`)" entryPoints: [websecure] service: bwpoc tls: { certResolver: letsencrypt } services: bwpoc: loadBalancer: passHostHeader: true serversTransport: bwpoc-insecure servers: [{ url: "https://192.168.200.155:443" }] serversTransports: bwpoc-insecure: insecureSkipVerify: true ``` ## Exploitation ```bash make waf # réapplique la conf BunkerWeb après modification de bunkerweb.yml make destroy # supprime les VM par l'API (confirmation demandée), modèle conservé ansible-playbook playbooks/99-destroy.yml -e destroy_template=true # supprime aussi le modèle, s'il a été fabriqué par le PoC ``` Sur `bw-waf`, la stack vit dans `/opt/bwpoc/bunkerweb` (`docker compose ps`, `logs`, `pull`...). Sur `bw-apps`, dans `/opt/bwpoc/apps`. ## Limites assumées du PoC - Certificats auto-signés : pour du Let's Encrypt en labo fermé, passer par le challenge DNS (`AUTO_LETS_ENCRYPT`, `LETS_ENCRYPT_CHALLENGE=dns`) avec votre fournisseur DNS. - Une seule instance BunkerWeb : pas de haute disponibilité, Redis est là pour la suite. - Les fonctions PRO de BunkerWeb (anti DDoS, monitoring Prometheus, cache, rapports) ne sont pas couvertes. - Le géoblocage (`BLACKLIST_COUNTRY`) et les DNSBL ne se testent pas depuis des IP privées. ## Arborescence ``` inventory/ hosts.yml (3 VM), single.yml (tout-en-un), group_vars/ playbooks/ 00 à 50 + site.yml, render.yml, 99-destroy.yml roles/ proxmox_template/ modèle Debian 13 cloud-init + agent QEMU, pool (root, une fois) proxmox_vm/ clonage par l'API (bwpoc@pve), DHCP, lecture des IP common/ paquets, agent QEMU, /etc/hosts docker/ Docker CE + compose (dépôt officiel) apps/ compose des applis, vitrine, filtrage DOCKER-USER bunkerweb/ compose BunkerWeb (template Jinja) client/ outils et script de tests tests/bwpoc-tests.sh batterie de tests fonctionnels examples/split/ compose générés, pour lecture docs/ schéma d'architecture (.excalidraw et .png) ``` ## Références - Documentation BunkerWeb : - Fichiers d'intégration officiels (docker, MariaDB, UI) : - Liste des réglages, extraite des `plugin.json` : - OWASP Core Rule Set :