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
- Code source : Dépôt nginx-hardened
- Image Docker : Docker Hub