— Artikel — № 064

064 —Security

Mod_security false positives: strakke whitelist zonder gaten

Woensdag 16:12: de WAF blokkeert de opslaan-knop van WordPress. De fix is niet SecRuleEngine Off, maar een strakke whitelist per regel, per route.

Bovenaanzicht van mod_security auditlog-prints, whitelist-map, WAF-messingplaatje, liniaal, potlood op linnen.
Hero · gestileerd stilleven№ 064

Woensdag, 16:12. Een agency lead stuurt ons een bericht: de WordPress-admin van hun klant slaat geen Gutenberg-blokken meer op die een <script>-tag in een JSON-attribuut bevatten. De CDN-logs geven 406 Not Acceptable terug. De mod_security audit log wijst naar OWASP CRS rule 941100. De site staat negen jaar live. De regel, vermoedelijk, ook. Iets duwde gisteren over de drempel en de editor is onbruikbaar tot het opgelost is.

Dit is de saaie helft van werken aan een verouderde site: een muur die je correct hebt opgetrokken blokkeert nu de mensen die het gebouw bezitten. De reflex is om de rule engine uit te zetten, de post te publiceren en het te vergeten. We hebben die reflex vier maanden later in een CVE zien veranderen. Er is een betere weg, en die is korter dan je denkt.

De audit log lezen voor je de config aanraakt

Voor je een regel uitschakelt, haal je de volledige mod_security audit entry op. Bij een standaard OWASP-installatie staat die op /var/log/modsec_audit.log; op cPanel-machines vind je hem meestal onder /usr/local/apache/logs/modsec_audit.log. De sectie die telt is die met label --H--:

[Wed Jun 09 16:12:41 2026] [error] [client 81.20.x.x] ModSecurity: Warning.
Pattern match "(?i)(?:<script[^>]*>[\s\S]*?)" at ARGS:content.
[file "/etc/modsecurity/crs/REQUEST-941-APPLICATION-ATTACK-XSS.conf"]
[line "94"] [id "941100"] [msg "XSS Attack Detected via libinjection"]
[data "Matched Data: <script> found within ARGS:content"]
[severity "CRITICAL"] [ver "OWASP_CRS/3.3.4"]
[hostname "client.example"] [uri "/wp-admin/post.php"]
[unique_id "ZmI4..."]

Drie dingen tellen: de rule ID (941100), de parameter die matchte (ARGS:content) en de URI (/wp-admin/post.php). Alles wat je daarna whitelistet moet naar alle drie verwijzen. Ken je alleen de rule ID, dan zet je meer open dan je wilde.

Vier schakelaars, de kleinste eerst

Het gangbare advies bij false positives is SecRuleRemoveById 941100. Daarmee schakel je XSS-detectie uit op elke request naar elke endpoint van de vhost. Voor een content editor die legitiem HTML POST, ruil je zo één probleem in voor een groter probleem. De OWASP CRS-docs noemen vier scopes, in volgorde van voorkeur:

  • ctl:ruleRemoveTargetById: verwijdert één rule van één parameter, alleen op de requests die je matcht.
  • SecRuleUpdateTargetById: herschrijft de target list van de rule, gescopet op een parameter of cookie.
  • SecRuleRemoveById: verwijdert de rule volledig binnen een gematchte locatie (gebruik dit binnen een <LocationMatch>, nooit op vhost-niveau).
  • SecRuleEngine Off: het laatste redmiddel, en alleen binnen een strak afgebakende <Location>.

Voor de Gutenberg-case hierboven is optie één de juiste keuze. Hij richt zich op de exacte parameter, op de exacte route, en laat XSS-detectie elders op de site gewoon staan.

Een path-gescopete whitelist in .htaccess

Zet dit in de .htaccess van de site, boven het WordPress rewrite-blok:

<IfModule mod_security2.c>
  # WP block editor: allow <script> inside the post body, nowhere else.
  <LocationMatch "^/wp-admin/post\.php$">
    SecRule REQUEST_METHOD "@streq POST" \
      "id:5000100,phase:1,pass,nolog,\
       ctl:ruleRemoveTargetById=941100;ARGS:content,\
       ctl:ruleRemoveTargetById=941160;ARGS:content,\
       ctl:ruleRemoveTargetById=941310;ARGS:content"
  </LocationMatch>

  # WP REST API equivalent (Gutenberg autosaves go here).
  <LocationMatch "^/wp-json/wp/v2/posts(/[0-9]+)?$">
    SecRule REQUEST_METHOD "@rx ^(POST|PUT|PATCH)$" \
      "id:5000101,phase:1,pass,nolog,\
       ctl:ruleRemoveTargetById=941100;ARGS:content,\
       ctl:ruleRemoveTargetById=941160;ARGS:content"
  </LocationMatch>
</IfModule>

De 5000xxx-band is gebruikelijk voor site-lokale regels; de CRS tuning guide raadt aan om site-rules boven het miljoen te houden bij nieuwe installaties, maar op verouderde machines waar al iemand aan de rule set heeft gezeten, kom je daar regelmatig conflicten tegen. Kies een band die niemand anders gebruikt en blijf daar.

Magento en Drupal: zelfde playbook, andere parameters

Magento 1- en 2-admins triggeren constant rules 920273 (invalid character) en 932100 (RCE) bij het bewerken van productbeschrijvingen, omdat TinyMCE ruwe HTML POST. De whitelist ziet er zo uit:

<LocationMatch "^/(index\.php/)?admin(_[a-z0-9]+)?/catalog/product/save">
  SecRule REQUEST_METHOD "@streq POST" \
    "id:5000200,phase:1,pass,nolog,\
     ctl:ruleRemoveTargetById=920273;ARGS:product[description],\
     ctl:ruleRemoveTargetById=920273;ARGS:product[short_description],\
     ctl:ruleRemoveTargetById=932100;ARGS:product[description]"
</LocationMatch>

Let op het admin-frontname-segment (admin_xyz123) dat Magento 2 per installatie randomiseert. Match het patroon, niet de letterlijke tekst.

Drupal 7 en 9 lopen tegen rule 942100 (SQLi libinjection) aan bij filter-format-submissions, omdat het body-veld URL-encoded binnenkomt met aanhalingstekens die al twee keer zijn ge-escaped. Eén regel, gescopet op /node/*/edit en de structure-pagina's:

<LocationMatch "^/(node/[0-9]+/edit|admin/structure/.*)">
  SecRule REQUEST_METHOD "@streq POST" \
    "id:5000300,phase:1,pass,nolog,\
     ctl:ruleRemoveTargetById=942100;ARGS:body[0][value],\
     ctl:ruleRemoveTargetById=942100;ARGS:body[und][0][value]"
</LocationMatch>

De rules die je strak laat staan

Een nuttige vraag voor je iets whitelistet: zou ik willen dat een junior dit over twee jaar zonder nadenken kopieert? De CRS-families die een admin-paneel vrijwel nooit legitiem nodig heeft, kun je beter met rust laten:

  • 930xxx (LFI): een content editor vraagt nooit om ../../etc/passwd.
  • 931xxx (RFI): geen enkel admin-endpoint hoort een remote URL als formulierwaarde te accepteren.
  • 932xxx (RCE): de Magento-case hierboven is een bekende uitzondering; al het andere blijft aan.
  • 933xxx (PHP injection): de editor accepteert HTML, geen PHP-tokens.

De verleiding, als een deadline drukt, is om SecRuleEngine DetectionOnly in te stellen voor de hele admin-map. De ModSecurity-reference noemt het wat het is: een uit-schakelaar met logging. Push je dat naar productie, zet er meteen een agenda-herinnering bij om het terug te draaien. Beter nog: doe het werk vandaag.

Controleren of de whitelist stand houdt

Na het deployen, drie checks. Eén: speel de oorspronkelijke POST opnieuw af en bevestig een 200:

curl -X POST https://client.example/wp-admin/post.php \
  -H "Cookie: wordpress_logged_in_xxx=..." \
  -F "content=<script>console.log(1)</script>" \
  -F "post_ID=42" -F "action=editpost" -i

Twee: stuur een bekend-slechte payload naar een niet-gewhitelistte endpoint en bevestig 406:

curl "https://client.example/?s=<script>alert(1)</script>" -i
# HTTP/1.1 406 Not Acceptable

Drie: tail tien minuten lang de audit log door echt verkeer heen en grep op de rule IDs die je hebt gewhitelistet. Gaan ze af op routes die je niet hebt gescopet, dan klopt je LocationMatch niet:

tail -F /var/log/modsec_audit.log | grep -E 'id "(941100|941160|942100)"'

Zijn de enige matches die je ziet de routes die je bedoelde, dan houdt de whitelist stand.

Hoe we het uiteindelijk in Pier hebben opgelost

WAF-whitelists zijn precies het soort aanpassing waarvan een jaar later niemand meer weet wie het schreef. De .htaccess belandt met drie bijna-identieke LocationMatch-blokken omdat twee engineers dezelfde false positive vanuit verschillende hoeken hebben opgelost. Toen we Pier bouwden, liepen we hier op elke legacy site waarmee we koppelden tegenaan. Elke wijziging die Pier in .htaccess schrijft (of via de MySQL editor naar wp_options) wordt verpakt in version history met één regel uitleg, zodat de volgende die het bestand opent kan zien wie wat heeft gewhitelistet en waarom, in plaats van te moeten gokken.

Het kleinste dat je vandaag nuttig kunt doen: pak de modsec_audit.log van gisteren, grep op elke rule ID die afgaat op /wp-admin, /user/login of je Magento admin-frontname, en schrijf de path-gescopete ctl:ruleRemoveTargetById voor degene die het vaakst afgaat. Eén regel, één parameter, één route. De muur blijft staan.

— Vragen —

Waarom mod_security niet gewoon uitschakelen op /wp-admin?

Omdat /wp-admin het meest waardevolle doelwit op de site is. Een WAF die uit staat over de hele admin-map maakt van de volgende plugin-RCE een volledige compromise. Whitelist de rule, de parameter, de route.

Welk rule ID-bereik moet ik voor site-lokale regels gebruiken?

De OWASP-conventie is alles boven 1.000.000 voor site-specifieke rules. Op verouderde machines is die band soms al in gebruik; kies een 7-cijferige prefix waar niemand anders aanzit en documenteer die in een comment.

Kan ik deze rules ook in wp-config.php of settings.php zetten?

Nee. mod_security draait voordat PHP geparsed wordt. De rule hoort in de Apache-config: .htaccess, een vhost-include of een conf.d-snippet. De volgorde van laden telt zwaarder dan welk bestand je kiest.