Aller au contenu

WAF a signatures

Le bord savait deja dire non : reputation d'adresse devant le trafic connu, challenge pour le robot qui insiste. Il lui manquait la reconnaissance de l'attaque elle-meme. Une injection SQL envoyee depuis une adresse propre, par un navigateur honnete, passait sans etre vue.

Ce document decrit le WAF pose sur la passerelle : Coraza, jeu de regles OWASP CRS, en Proxy-WASM.

Pourquoi Coraza et pas le decideur maison

Le decideur note un comportement. Il compte, il compare, il rend un score, et il ne connait a l'avance aucune attaque. Le CRS fait l'inverse : des milliers de signatures entretenues par une communaute, qui reconnaissent une famille d'attaque et son contexte (injection SQL, XSS, traversee de repertoire, execution de commande, inclusion de fichier, scanneurs connus).

Les deux ne se remplacent pas. Le WAF bloque l'attaque evidente en une microseconde, sans rien savoir du client. Le challenge occupe le robot qui n'attaque pas encore, celui qui aspire le catalogue ou teste des comptes.

Coraza est un moteur de regles ModSecurity ecrit en Go, compile en Proxy-WASM. Il s'installe donc comme n'importe quel filtre Envoy, sans processus a part, et s'applique a la passerelle Istio.

Ou il s'insere

L'ordre compte, du moins couteux au plus couteux :

  1. Reputation d'adresse, dans le noyau, par Cilium. Une adresse connue comme mauvaise ne produit aucune requete HTTP. Cout quasi nul.
  2. WAF, phase AUTHN, priorite 20. Signatures sur l'URL, les en-tetes, les cookies et le corps.
  3. Challenge, phase AUTHN, priorite 10. Proof-of-work ou captcha, selon le score du decideur.

Le sens des priorites surprend : dans une meme phase, une valeur plus grande s'execute plus tot. Le WAF porte donc 20 et le challenge 10. Mesure a l'appui : avec 5 et 10, une injection SQL recevait la page de defi, preuve que le challenge etait passe en premier. 4. Application.

Le WAF passe avant le challenge pour une raison simple : faire resoudre un proof-of-work a un client qui envoie une injection SQL est du gaspillage. Une attaque se reconnait sans lui demander son avis.

Ce qui est pose

L'objet est un WasmPlugin de la passerelle, declare dans tofu/edge/waf.tf. Le module vient d'un artefact OCI public, epingle par version : une mise a jour du CRS change le comportement du bord, elle ne doit pas arriver par surprise.

Les reglages se font par variables :

  • waf_enabled : poser ou non le filtre.
  • waf_mode : blocking (403 sur declenchement) ou detection (evaluation et journalisation seulement).
  • waf_paranoia_level : 1 a 4. Le niveau 1 suffit a un site classique, les niveaux hauts sont reserves a un trafic deja accorde.
  • waf_max_body_bytes : au dela, la requete est refusee plutot que passee sans inspection. Un corps non inspecte est un angle mort, pas une optimisation.
  • waf_fail_strategy : FAIL_OPEN par defaut. Une panne du module laisse passer le trafic plutot que de couper le service.

Deux chemins echappent au moteur : /.well-known/acme-challenge/, sans quoi le renouvellement de certificat serait bloque, et /__challenge/, qui transporte des jetons que le CRS lit comme des attaques.

Resultats mesures

Sur https://demo.guillaume.ink, le 19 octobre 2026, niveau de paranoia 1, mode blocking.

Bloquees, code 403 :

  • injection SQL dans l'URL et dans un corps POST
  • XSS reflechi
  • traversee de repertoire encodee (%2e%2e%2f)
  • injection de commande (;cat /etc/passwd)
  • inclusion de fichier (php://filter/...)
  • agents sqlmap et nikto
  • methode TRACE

Passees, code 200 :

  • requete legitime, y compris avec un profil de navigateur complet
  • apostrophe francaise en parametre (?q=l'ete a Rennes)
  • jeton JWT en en-tete Authorization
  • chaine base64 en parametre
  • adresse electronique et URL encodee en parametres

Latence : 40 a 45 ms sur dix requetes consecutives, contre 39 ms avant le WAF. L'ecart est dans le bruit de mesure.

Limites connues

Pas d'inspection des reponses. SecResponseBodyAccess est desactive pour contenir l'empreinte memoire. Une fuite de donnees dans une reponse n'est donc pas vue. Cout a reevaluer si le bord sert des donnees personnelles.

Le chemin est normalise avant le filtre. Envoy reduit ../ avant que le moteur ne voie l'URL, donc une traversee de repertoire en clair arrive deja aplatie. Seules les formes encodees survivent jusqu'au filtre.

Une passerelle, une configuration. Le filtre est attache a la passerelle : la politique se decide par domaine, mais le niveau de paranoia et le seuil d'anomalie restent communs. Un niveau par domaine demanderait une passerelle par domaine.

La memoire du module est fixe par la taille du CRS. Le module embarque et compile les regles ; compte quelques dizaines de Mo par instance de passerelle, a multiplier par le nombre de replicas.

Regler les faux positifs

Un WAF se met en route en detection, jamais directement en blocking. La methode :

  1. Passer waf_mode = "detection", laisser tourner sur du trafic reel.
  2. Relever les identifiants de regles qui declenchent (id:942100, etc.).
  3. Pour chaque faux positif, soit une exclusion ciblee dans les directives (SecRuleRemoveById), soit un reglage applicatif si c'est l'application qui envoie une charge que le CRS juge douteuse.
  4. Repasser en blocking quand le bruit est retombe.

Les exclusions doivent rester ciblees. Desactiver une famille entiere de regles revient a retirer le WAF pour la garder au tableau de bord.

La politique par domaine

Tout ce qui precede se decide desormais depuis la console, domaine par domaine, et s'applique en une quinzaine de secondes sans redemarrer la passerelle.

Trois leviers. Le mode (blocking ou detection), les familles de regles a retirer (dix familles exposees : injection SQL, XSS, execution de commande, inclusion locale et distante, injection PHP, injection Java, protocole, scanners, fixation de session), et des exclusions par identifiant de regle.

Un seul module. La difference se fait en phase 1, avant les regles CRS, avec les actions que ModSecurity prevoit pour cela :

SecRule REQUEST_HEADERS:Host "@rx ^demo\.guillaume\.ink$" \
    "id:911000,phase:1,pass,nolog,ctl:ruleRemoveByTag=attack-sqli"

Verifie sur le banc. Meme charge ?id=1' OR '1'='1 : les deux domaines bloquent en 403 ; famille SQL retiree pour la demo, elle repond 200 pendant que la console reste en 403, et le trafic normal reste 200 des deux cotes ; famille remise, les deux bloquent de nouveau.

Qui ecrit. La console est la source de verite : elle calcule la configuration complete et la pousse a l'API Kubernetes avec son compte de service, qui ne peut que lire et modifier ce module dans ce namespace. OpenTofu ne fait que le creer au demarrage.

La collision d'identifiants qui rendait le niveau inoperant

Les regles de la plateforme portaient les identifiants 900000 a 900002. C'est exactement la plage que le fichier de configuration du CRS utilise pour ses propres regles. La collision faisait ignorer une regle sans rien dire : le reglage du niveau de paranoia etait sans effet depuis le debut, et rien ne le montrait.

Toutes les regles de la plateforme vivent maintenant entre 910000 et 919999, entre la configuration du CRS (900xxx) et ses fichiers de regles (920xxx et au-dela). Un test verifie qu'aucun identifiant genere ne sort de cette plage.

Ce qui ne se regle pas par domaine

Le niveau de paranoia et le seuil d'anomalie restent communs a la passerelle. Chaque fichier de regles du CRS commence par une regle qui se saute elle-meme selon tx.detection_paranoia_level : ce niveau decide si une regle est evaluee du tout. Il est lu au debut de la transaction, et les essais menes sur le banc montrent qu'il ne se change pas utilement par hote dans une configuration partagee. Le documenter vaut mieux que le promettre.