WAF nginx : ModSecurity + OWASP CRS FROM scratch

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èteglob() 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_name explicite
  • 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 /healthz désactive auth_basic et modsecurity pour é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é :

  1. Les probes Kubernetes utilisent un header Host générique
  2. La requête tombe sur le default server implicite
  3. auth_basic retourne 401 (Unauthorized)
  4. Le probe échoue → le pod passe en CrashLoopBackOff
  5. L'ancien pod continue avec une config stale
  6. 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

Articles connexes


Commentaires

Laisser un commentaire

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

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.