WAF nginx : ModSecurity + OWASP CRS FROM scratch

Rôle

Pare-feu applicatif protégeant tous les services exposés publiquement. Il analyse chaque requête HTTP entrante selon les règles OWASP Core Rule Set et refuse celles qui correspondent à des attaques connues. Cet article ne traite que de l'image ; le réglage des règles — exclusions, faux positifs, score d'anomalie — a le sien.

Position dans la chaîne

flowchart LR
    Traefik["Traefik<br/>Gateway API"] --> WAF["WAF nginx<br/>ModSecurity + CRS"]
    WAF -->|"requête acceptée"| Cache["Varnish<br/>puis l'application"]
    WAF -->|"requête refusée"| R403["403"]

Le WAF est le seul point d'entrée des sites web du cluster : il porte l'unique HTTPRoute, trie les domaines par server_name, inspecte, puis passe la main au cache. Tout ce qui se passe avant lui — terminaison TLS, filtrage sur l'adresse source — est décrit dans le reverse proxy.

Un point mérite d'être dit ici, parce qu'il ne se voit pas sur un schéma : l'inspection se règle par domaine, avec un modsecurity on|off dans chaque bloc server. Le même pod peut donc inspecter un domaine et se contenter de router le voisin — et la configuration a l'air normale dans les deux cas.

Ce que l'image portait en trop

Un contrôle d'inventaire posé en CI, qui compare le contenu réel de l'image à un manifeste versionné, a mis au jour une base de données GeoIP que rien n'ouvrait : ni la configuration, ni un module chargé. Son retrait a fait passer cette image de 74,6 à 15,1 Mio — à elle seule, la moitié de la réduction obtenue sur l'ensemble du parc.

Rien dans le Dockerfile ne signalait le problème : la base était copiée délibérément, par un module qui avait été retiré depuis. Ce genre de reliquat ne se voit pas en relisant la recette, seulement en regardant le résultat.

Une fois nettoyée, la répartition est parlante : en dehors des données inertes que toutes les images portent, 408 chemins appartiennent en propre à cette image, dont 314 sont les fichiers de règles du CRS. Les règles pèsent donc les trois quarts de ce que l'image transporte pour son service — ce qui est, pour un WAF, exactement le bon rapport.

Composants

nginx est compilé depuis les sources, l'archive vérifiée par signature GPG avec une clé épinglée. Les modules sont réduits au strict nécessaire : ngx_http_modsecurity_module, chargé dynamiquement.

Le moteur ModSecurity est lui aussi compilé depuis les sources, avec son connecteur nginx. C'est l'étape longue du build — une vingtaine de minutes — ce qui rend le cache de la CI déterminant sur les constructions itératives.

Le Core Rule Set fournit les règles génériques. L'image ne fait que les transporter : ce qu'elles détectent et comment on les ajuste appartient à l'article sur le réglage.

Charger les règles quand Include est ignoré

Le CRS distribue ses règles avec un Include à base de glob. Dans une image FROM scratch, il n'y a pas de libc complète, donc pas de glob() : le stage de préparation du Dockerfile expanse donc tous les motifs en une liste statique de fichiers, écrite une fois pour toutes dans l'image.

Ce mécanisme existe toujours dans l'image, mais ce déploiement ne s'en sert pas : la directive Include de ModSecurity y est ignorée en silence. Les règles sont donc chargées par une directive nginx par fichier, dans un ordre explicite — les exclusions d'abord, puis les familles de règles, puis les règles de réponse.

Le bénéfice n'est pas la performance, c'est le mode d'échec. Avec un Include silencieux, un fichier absent désarme une partie du CRS sans rien dire. Avec une directive par fichier, un fichier absent fait échouer nginx -t : le pod ne démarre pas, au lieu de démarrer troué.

Déploiement K3s

Namespace waf, chart Helm, un fichier de configuration par domaine posé depuis une ConfigMap — chacun avec son server_name explicite.

Ce pod utilise les deux modes de montage à la fois, et c'est la source de confusion la plus courante à son sujet :

Fichier Montage Suivi par kubelet
le répertoire conf.d volume entier oui, en une minute environ
nginx.conf, les règles ModSecurity subPath non

Dans les deux cas il faut un kubectl rollout restart : pour les seconds parce que le fichier ne bouge pas, pour les premiers parce que nginx ne relit pas sa configuration tout seul — il applique ce qu'il a chargé au démarrage, même quand le fichier sur disque a changé. La règle générale et son diagnostic sont détaillés dans le cluster K3s.

Configuration par site

Chaque service protégé a son fichier dédié, sur le même patron :

server {
    listen 80;
    server_name exemple.domaine.fr;

    modsecurity on;
    include conf.d/modsec-rules;

    location / {
        proxy_pass http://backend-amont;
        include conf.d/proxy-headers;
        include conf.d/security-headers;
    }

    location = /healthz {
        access_log off;
        modsecurity off;
        return 200 "ok\n";
    }
}

L'include désigne la liste ordonnée de règles décrite plus haut, et non un fichier de configuration unique : c'est ce qui permet à un domaine d'être armé ou non sans toucher au reste du pod.

Pages d'erreur personnalisées

Les pages 403, 404 et 50x sont intégrées à l'image. Elles affichent un message générique, sans rien révéler de l'infrastructure qui les sert.

Healthcheck

Le binaire d'entrée écrit en Go effectue une requête HTTP sur /healthz avec un en-tête Host local. Il sert aux sondes de vivacité et de disponibilité du cluster. Le endpoint désactive auth_basic et l'inspection, pour que la santé du pod ne dépende pas d'une règle applicative — ce qui n'est pas un détail, comme le montre la section suivante.

Le serveur par défaut se décide par ordre alphabétique

Sans directive default_server explicite, nginx promeut le premier bloc server chargé — c'est-à-dire le premier dans l'ordre alphabétique des fichiers. Les sondes du cluster arrivent avec un en-tête Host qui ne correspond à aucun server_name : elles tombent donc sur ce bloc promu par défaut.

Si ce bloc est protégé par auth_basic, la sonde reçoit un 401, le pod est déclaré malade et redémarre en boucle — pendant que l'ancien pod continue de servir une configuration périmée. Le nouveau domaine, lui, ne fonctionne jamais.

Le piège n'est pas le 401, c'est le déclencheur : renommer un fichier de configuration suffit à changer quel domaine devient serveur par défaut. La parade tient donc dans une règle locale — tout bloc protégé par auth_basic expose son propre location = /healthz avec auth_basic off; modsecurity off;. La santé du pod cesse alors de dépendre de l'ordre alphabétique.

Build

La construction est longue à cause de la compilation du moteur, et le cache de la CI est ce qui rend les itérations supportables. Les sources sont vérifiées cryptographiquement avant compilation. Les étapes elles-mêmes sont dans le Dockerfile du dépôt ; les recopier ici ne ferait qu'en créer une seconde version.


Liens

Articles connexes