Rôle
Web Application Firewall protégeant tous les services exposés publiquement. Analyse chaque requête HTTP entrante selon les règles OWASP Core Rule Set et bloque les attaques connues (SQLi, XSS, RCE, path traversal, etc.).
Position dans la chaîne
flowchart LR
Internet((Internet)) --> HAProxy["HAProxy<br/>TLS"]
HAProxy -->|"re-encryption"| Traefik["Traefik<br/>Ingress"]
Traefik --> WAF["WAF nginx<br/>ModSecurity + CRS"]
WAF -->|"clean request"| Backend["Backend<br/>application"]
WAF -->|"blocked"| R403["403 Forbidden"]
Le WAF est le dernier point de contrôle avant les applications. HAProxy gère le TLS et le routage SNI, Traefik ajoute le rate-limiting CrowdSec, puis le WAF inspecte le contenu des requêtes.
Image FROM scratch
| Propriété | Valeur |
|---|---|
| Image | nginx-waf-hardened |
| Version | 1.30.3 |
| Taille | ~22 MB |
| Base | FROM scratch (tier Platine) |
| Composants | nginx + libmodsecurity3 + OWASP CRS 4.x |
Composants
nginx
Compilé depuis les sources avec vérification GPG de l'archive (clé épinglée). Modules activés au minimum strict : ngx_http_modsecurity_module chargé dynamiquement.
ModSecurity v3
Connecteur nginx pour le moteur libmodsecurity3. Compilation depuis les sources (~20 minutes). Le cache GitHub Actions est critique pour éviter de recompiler à chaque push.
OWASP Core Rule Set 4.x
Ensemble de règles générique détectant les patterns d'attaque courants. Scoring par anomalie (seuil configurable par site).
Workaround CRS glob()
Problème : les fichiers de configuration CRS utilisent Include *.conf qui repose sur glob() de la libc. Dans une image FROM scratch, il n'y a pas de libc complète → glob() ne fonctionne pas.
Solution : dans le stage prep du Dockerfile, un script expanse tous les globs CRS en une liste statique de fichiers Include dans crs-rules.conf. Ce fichier est ensuite copié dans l'image finale scratch.
# Stage prep : expansion des globs
for f in /opt/crs/rules/*.conf; do
echo "Include $f" >> /tmp/crs-rules.conf
done
Déploiement K3s
- Namespace :
waf - Helm chart avec overlay ConfigMap pour les fichiers
conf.d/domain.conf - Chaque domaine a son server block avec
server_nameexplicite - Les ConfigMaps sont montées en volume (mise à jour automatique par kubelet ~1 min)
- Attention : nginx ne relit PAS les fichiers sans reload → rollout restart obligatoire après changement de config
Pages d'erreur personnalisées
Les pages 403, 404 et 50x sont intégrées dans l'image (baked-in) sous /usr/share/nginx/html/. Elles affichent un message générique sans révéler d'information technique sur l'infrastructure.
Healthcheck
L'init Go effectue un HTTP GET sur /healthz avec le header Host: localhost :
- Vérifie que nginx répond correctement
- Utilisé par les probes Kubernetes (liveness + readiness)
- Le endpoint
/healthzdésactiveauth_basicetmodsecuritypour éviter les faux négatifs
Configuration par site
Chaque service protégé a un fichier conf.d/domain.conf dédié :
server {
listen 80;
server_name exemple.domaine.fr;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;
location / {
proxy_pass http://backend-service:port;
}
location = /healthz {
auth_basic off;
modsecurity off;
return 200 'OK';
}
}
Limites et exceptions
SecRequestBodyLimit (10 MB)
La limite de taille du corps de requête est imposée au niveau du connecteur ModSecurity. Elle n'est pas contournable via ctl:requestBodyAccess=Off dans les règles CRS.
Uploads binaires (Immich)
Pour les services nécessitant l'upload de fichiers volumineux (photos, vidéos), ModSecurity est désactivé sur les locations spécifiques :
location /api/assets {
modsecurity off;
proxy_pass http://immich-server:3001;
client_max_body_size 50G;
}
Build
- Durée : ~20 minutes (compilation ModSecurity)
- Cache GHA : critique pour les builds itératifs
- GPG key pinning : la source nginx est vérifiée cryptographiquement avant compilation
- 4 stages : builder (compile) → prep (CRS expansion + layout) → healthcheck (Go init) → scratch (assemblage final)
Gotcha : default_server + auth_basic
nginx sans directive default_server explicite utilise le premier server block chargé alphabétiquement comme serveur par défaut.
Si ce serveur par défaut a auth_basic activé :
- Les probes Kubernetes utilisent un header Host générique
- La requête tombe sur le default server implicite
auth_basicretourne 401 (Unauthorized)- Le probe échoue → le pod passe en CrashLoopBackOff
- L'ancien pod continue avec une config stale
- Le nouveau domaine ne fonctionne jamais
Règle absolue : tout server block avec auth_basic DOIT avoir un location = /healthz avec auth_basic off; modsecurity off; pour que les probes passent même si ce block devient le default server implicite.
Liens
- Code source : nginx-hardened
- Image Docker : Docker Hub
Laisser un commentaire