— Article — № 064

064 —Security

Mod_security false positives: whitelisting without floodgates

WAF blocks the WordPress save button at 16:12 on a Wednesday. The fix isn't SecRuleEngine Off; it's a tight whitelist, rule by rule, route by route, that holds.

Overhead photo of mod_security audit log printouts, whitelist folder, WAF brass plate, ruler, pencil on bone linen.
Hero · staged still№ 064

Wednesday, 16:12. An agency lead pings us: their client's WordPress admin has stopped saving Gutenberg blocks that contain a <script> tag in any JSON attribute. The CDN logs return 406 Not Acceptable. The mod_security audit log fingers OWASP CRS rule 941100. The site has been live for nine years. The rule has, presumably, also been live for nine years. Something tipped it over the threshold yesterday and the editor is unusable until it gets sorted.

This is the boring half of working on a legacy site: a wall you put up correctly is now blocking the people who own the building. The reflex is to switch the rule engine off, ship the post, and forget. We have watched that reflex turn into a CVE four months later. There is a better path, and it is shorter than you think.

Reading the audit log before touching the config

Before any rule gets disabled, get the full mod_security audit entry. On a default OWASP install it lives at /var/log/modsec_audit.log; on cPanel boxes it is usually under /usr/local/apache/logs/modsec_audit.log. The section that matters is the one labelled --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..."]

Three pieces matter: the rule ID (941100), the matched parameter (ARGS:content), and the URI (/wp-admin/post.php). Anything you whitelist later should reference all three. If you only know the rule ID you are about to open more than you wanted to.

Four switches, smallest first

The common advice for false positives is SecRuleRemoveById 941100. That kills XSS detection on every request to every endpoint on the vhost. For a content editor that legitimately POSTs HTML, that swaps one problem for a wider one. The OWASP CRS docs list four scopes you should reach for, in order of preference:

  • ctl:ruleRemoveTargetById: removes a single rule from a single parameter on the requests you match.
  • SecRuleUpdateTargetById: rewrites the rule's target list, scoped to a parameter or cookie.
  • SecRuleRemoveById: removes the rule entirely from a matched location (use inside a <LocationMatch>, never at vhost root).
  • SecRuleEngine Off: the last resort, and only inside a tight <Location>.

For the Gutenberg case above, the right reach is option one. It targets the exact parameter, on the exact route, and leaves XSS detection live everywhere else on the site.

A path-scoped whitelist in .htaccess

Drop this in the site's .htaccess, above any WordPress rewrite block:

<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>

The 5000xxx band is conventional for site-local rules; the CRS tuning guide recommends keeping site rules above one million in fresh installs, but on legacy boxes that touched the rule set you will often find conflicts up there. Pick a band that nobody else has used and stay in it.

Magento and Drupal: same playbook, different parameters

Magento 1 and 2 admins trip rules 920273 (invalid character) and 932100 (RCE) constantly on product description edits, because TinyMCE POSTs raw HTML. The whitelist looks like this:

<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>

Note the admin-frontname segment (admin_xyz123) which Magento 2 randomises per install. Match the pattern, not the literal.

Drupal 7 and 9 hit rule 942100 (SQLi libinjection) on filter-format submissions because the body field arrives URL-encoded with quotes already escaped twice. One rule, scoped to /node/*/edit and the structure pages:

<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>

The rules to keep on the strict side

A useful question to ask before you whitelist: would I want a junior copy-paste this in two years? The CRS families that an admin panel almost never legitimately needs are worth leaving alone:

  • 930xxx (LFI): a content editor never asks for ../../etc/passwd.
  • 931xxx (RFI): no admin endpoint should accept a remote URL as a form value.
  • 932xxx (RCE): the Magento case above is a known edit; everything else stays on.
  • 933xxx (PHP injection): the editor takes HTML, not PHP tokens.

The temptation, when a deadline is pressing, is to set SecRuleEngine DetectionOnly for the whole admin folder. The ModSecurity reference calls this what it is: an off switch with logging. If you ship that to production, write a calendar reminder to undo it. Better yet, do the work today.

Verifying the whitelist holds

After deploying, three checks. First, replay the original POST and confirm 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

Second, send a known-bad payload at a non-whitelisted endpoint and confirm 406:

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

Third, tail the audit log through ten minutes of real traffic and grep for the rule IDs you whitelisted. If they fire on routes you did not scope, your LocationMatch is wrong:

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

When the only matches you see are the routes you intended, the whitelist holds.

What we ended up doing on Pier

WAF whitelists are exactly the kind of edit that, a year later, nobody remembers writing. The .htaccess file ends up with three near-duplicate LocationMatch blocks because two engineers solved the same false positive from different angles. When we built Pier we ran into this on every legacy site we docked with. Every change Pier writes to .htaccess (or through the MySQL editor into wp_options) is wrapped in version history with a one-line reason, so the next person opening the file can see who whitelisted what and why instead of guessing.

The smallest useful thing you can do today: pull last night's modsec_audit.log, grep for any rule ID firing on /wp-admin, /user/login, or your Magento admin frontname, and write the path-scoped ctl:ruleRemoveTargetById for the one that fires most. One rule, one parameter, one route. The wall stays up.

— Questions —

Why not just disable mod_security on /wp-admin?

Because /wp-admin is the highest-value target on the site. A WAF off across the admin folder turns the next plugin RCE into a full compromise. Whitelist the rule, the parameter, the route.

What rule ID range should site-local rules use?

OWASP convention is anything above 1,000,000 for site-specific rules. On legacy boxes that band is sometimes already used; pick a 7-digit prefix nobody else has touched and document it in a comment.

Can I put these rules in wp-config.php or settings.php instead?

No. mod_security runs before PHP is parsed. The rule must live in Apache config: .htaccess, a vhost include, or a conf.d snippet. Load order matters more than which file you choose.

Does this apply to ModSecurity v3 (libmodsecurity)?

The directives are identical between v2 and v3; what changes is the loader. Replace <IfModule mod_security2.c> with <IfModule security2_module> if your distro packages v3 under that name.