Un pare-feu applicatif ne se règle pas en retirant les règles qui gênent. Chaque exclusion a un prix, et ce prix se mesure — sinon il se découvre des mois plus tard. Cet article documente le réglage du Core Rule Set sur ce homelab : comment séparer un faux positif d'un scan, comment couper le strict minimum, et pourquoi retirer une seule règle peut désarmer un domaine entier sans qu'aucun fichier ne devienne faux.
L'image qui porte le moteur et la façon dont elle est construite sont décrites dans le WAF nginx ; la chaîne complète qui mène jusqu'à lui, dans le reverse proxy. Ici, il n'est question que du réglage.
La règle qui bloque n'est pas une règle de détection
Le CRS ne décide pas règle par règle. Chaque correspondance ajoute des points à un score d'anomalie, et une seule règle, en fin de parcours, compare ce score au seuil et prononce le refus : 949110, dans REQUEST-949-BLOCKING-EVALUATION.conf.
La conséquence est contre-intuitive. Retirer une règle de détection réduit la détection d'une attaque précise. Retirer 949110 ne réduit aucune détection : tout continue d'être analysé, compté et journalisé — et plus rien n'est jamais bloqué.
C'est ce qui est arrivé à un domaine de ce homelab. Une exclusion portait ctl:ruleRemoveById=949110, avec pour motif écrit dans le fichier que « l'API Home Assistant génère beaucoup de faux positifs ». Le vhost, lui, portait bien modsecurity on;.
Les deux affirmations étaient exactes. Le domaine était pourtant en détection seule. Ce qui rend le cas instructif n'est pas la faute, c'est qu'aucun fichier ne devenait faux : il fallait lire les deux ensemble pour voir le trou.
flowchart TD
R["Requête"] --> E{"ruleEngine coupé ?"}
E -- "oui" --> P["Passe sans inspection"]
E -- "non" --> M["Règles 911 à 944<br/>chaque correspondance<br/>ajoute au score"]
M --> B{"inspection du corps ?"}
B -- "coupée" --> H["Corps ignoré<br/>URI et en-têtes<br/>restent inspectés"]
B -- "active" --> C["Corps inspecté"]
H --> S["Score d'anomalie"]
C --> S
S --> N["949110<br/>évaluation bloquante"]
N -- "score au-dessus du seuil" --> D["403"]
N -- "sinon" --> OK["Transmis au backend"]
style N fill:#e74c3c,color:#fff
style D fill:#c0392b,color:#fff
Le diagramme montre pourquoi les quatre leviers de réglage n'ont pas du tout la même portée : couper le moteur écarte tout, couper le corps laisse l'URI et les en-têtes armés, retirer une règle nommée retire ses points du score, et retirer 949110 supprime la décision elle-même.
Détecter n'est pas bloquer, et seule une requête le prouve
Aucune lecture de configuration n'établit qu'un domaine est protégé. Un ctl: posé dans un autre fichier, ou une délégation vers une couche qui a changé, suffit à désarmer un vhost dont la configuration a l'air correcte.
La parade est une sonde inversée : envoyer une charge que le CRS doit refuser, et n'accepter que le refus. Un moniteur qui exige 403 et vire au rouge sur 200 dit exactement ce qu'on veut savoir — un vert-200 signifie WAF désarmé. Le principe et son intégration à la supervision sont décrits dans la supervision.
Ces sondes ont attrapé, le jour même de leur pose, un déploiement qui avait publié la mauvaise branche : le job était vert, le pod recréé, la configuration inchangée. Aucun journal de CI ne pouvait le montrer, puisque la CI avait bien fait son travail.
Séparer un faux positif d'un scan : le journal d'audit
La question utile n'est pas « combien de règles se déclenchent » mais « d'où viennent ces requêtes ». Le journal d'audit de ModSecurity donne les deux.
Sur seize jours, ce domaine comptait 2886 correspondances : 2127 d'origine externe contre 19 internes. Les motifs dominants ne laissent aucun doute — .env demandé 1565 fois, puis phpinfo.php, .git/, config.php, credentials.json, et des charges Node.js (__proto__, process.mainModule). Ce ne sont pas des faux positifs, c'est le WAF qui travaille.
Les deux vrais faux positifs se sont isolés dans le même journal :
- les
PUTde l'API Tandoor et du pont Bluetooth, refusés par 911100, « Method is not allowed by policy » ; - les requêtes de Grafana vers VictoriaLogs, dont la syntaxe de requête contient des tubes (
| sort by (_time),| base64_decode) que 932235 (injection de commande) et 942100 (injection SQL) lisent comme une attaque.
Deux faux positifs sur 2886 correspondances. C'est le rapport à avoir en tête avant de retirer quoi que ce soit — et c'est l'inverse de l'impression que donne un journal qu'on parcourt sans compter les origines.
Couper le moins possible
Les quatre leviers, du plus large au plus étroit :
| Ce qu'on coupe | Directive | Portée réelle |
|---|---|---|
| le moteur entier | ctl:ruleEngine=Off |
à réserver aux endpoints de santé |
| la décision de blocage | ctl:ruleRemoveById=949110 |
désarme le domaine, à ne jamais faire |
| une règle de détection | ctl:ruleRemoveById sur cette règle |
ses points disparaissent du score |
| l'inspection du corps | ctl:requestBodyAccess=Off |
l'URI et les en-têtes restent armés |
Le levier le plus utile est le dernier, parce qu'il se chaîne. L'exclusion posée pour Grafana ne s'applique qu'à la conjonction d'un domaine, d'une méthode et d'un chemin :
SecRule REQUEST_HEADERS:Host "@rx ^(home|tandoor)\.jbsky\.fr$" \
"id:1101,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq POST" "chain"
SecRule REQUEST_URI "@rx ^/api/hassio_ingress/[^/]+/api/ds/query" \
"ctl:requestBodyAccess=Off"
Tout le reste du domaine garde le CRS complet, corps compris. Le second faux positif, lui, ne relevait pas d'une exclusion mais d'une politique de méthodes : 911100 refusait des PUT parce que la liste des méthodes autorisées ne les contenait pas. La réponse tient alors dans une variable, pas dans une règle retirée :
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT DELETE PATCH'
Quand le contenu légitime est indistinguable de l'attaque
Un cas résiste à tout réglage fin : celui où le contenu légitime est la charge que le CRS cherche.
Le corps d'un article de ce blog cite des chemins (/etc/passwd, /var/www), des commandes (whoami, kubectl exec) et des variables shell. Mesuré au banc avec les articles réels du site : douze refus sur douze, familles 930 (accès fichier), 932 (exécution de commande) et 933 (injection PHP).
Il n'y a pas de règle à ajuster — le texte d'un article sur l'administration système est, caractère pour caractère, ce qu'une règle anti-injection doit attraper. L'inspection du corps est donc coupée sur les seuls chemins d'écriture de l'API de publication, et rien d'autre. Ce qui reste armé sur ces mêmes chemins : l'URI et la chaîne de requête, les en-têtes, et les règles de réponse.
Le prix est réel et il est écrit dans la règle : un corps hostile posté sur ces chemins n'est plus inspecté par le WAF. L'application le rejette faute d'authentification, mais le pare-feu ne le verra pas. C'est le compromis assumé pour que le site reste éditable.
La limite de taille du corps n'est pas une règle du CRS
C'est la confusion la plus coûteuse du réglage. SecRequestBodyLimit est appliquée par le connecteur, avant que la moindre règle soit évaluée. ctl:requestBodyAccess=Off ne la lève donc pas : l'une plafonne la taille de ce qui est mis en tampon, l'autre décide si ce tampon est inspecté. Deux niveaux différents, deux effets différents.
On croit avoir désactivé l'inspection, et un téléversement volumineux ressort quand même en refus. Pour les envois binaires, la parade n'est pas une exclusion CRS mais une décision au niveau du vhost.
Et il faut dire ce que fait réellement la production plutôt que la bonne pratique qu'on aimerait décrire : sur le domaine qui sert la photothèque, ce n'est pas une location d'envoi qui est exemptée, c'est location ^~ / — tout le site. Le choix se défend (des blobs binaires, pas de surface d'injection applicative), mais c'est une exemption de domaine, pas une exception ciblée. L'appeler autrement serait se raconter une histoire, et c'est précisément ce genre d'histoire qui produit un trou de trois mois.
Écrire le prix à côté de l'exclusion
Chaque règle d'exclusion de ce homelab porte, en commentaire immédiatement au-dessus, ce qui a été mesuré, quand, et ce qu'on perd.
La raison tient dans l'exemple d'ouverture : le motif de l'exclusion de 949110 avait bien été écrit — « l'API génère beaucoup de faux positifs ». Ce qui manquait, c'était la phrase disant que la retirer supprimait tout blocage sur le domaine. Un motif n'est pas un prix. Le premier justifie le geste au moment où on le fait ; seul le second permet de le relire.
Comment les règles sont réellement chargées
Un détail d'implémentation qui a son importance : dans ce déploiement, la directive Include de ModSecurity est ignorée en silence. Les règles sont donc chargées par une directive nginx par fichier, dans un ordre explicite — les exclusions avant le CRS, puis les familles de règles, puis les règles de réponse.
Le bénéfice est moins la performance que 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é. Entre une panne visible et une protection absente, le choix se fait tout seul.
Liens
- Règles OWASP : Core Rule Set
- Moteur : ModSecurity