Custom ModSecurity Rules for WordPress and WooCommerce Attacks
If you run WordPress or WooCommerce on your own server, most of the unwanted traffic it receives falls into a few recognisable patterns: password guessing against the login page, abuse of XML-RPC, bots hammering shop filter and add-to-cart URLs, card testers working through the checkout, scanners probing for files that should never be public, and crawlers that fetch far more than they need. This guide gives you a small set of custom ModSecurity rules (ModSecurity is the web application firewall, or WAF, module for Apache, sometimes written mod_security) that answer each of those patterns. For every rule you get a plain-language explanation of what it protects against, then instructions to install it on Plesk, cPanel & WHM, ISPConfig or a plain Apache server and to trial it safely before it blocks anything.
These rules sit alongside a full rule set such as the OWASP Core Rule Set or one of the rule sets your control panel offers. They add WordPress and WooCommerce awareness that a generic rule set does not have; they do not replace it.
Last reviewed: 23 September 2026, against the ModSecurity 2.9 reference manual and the current Plesk Obsidian, cPanel & WHM and ISPConfig 3 documentation. This guide is kept current against ModSecurity. The rules are examples for your own server, not a description of any Noiz server configuration. It complements, and does not replace, the official ModSecurity documentation linked below.
Official Documentation Reference
Section titled “Official Documentation Reference”- ModSecurity Reference Manual (v2.x), for the
idranges, the processing phases and thesetuid,setvar,expirevar,ctlandsanitiseArgactions used below - OWASP Core Rule Set documentation, the general-purpose rule set these rules are designed to sit beside
- Plesk Obsidian: Web Application Firewall (ModSecurity)
- WHM: ModSecurity Tools and WHM: ModSecurity Configuration
- Debian package libapache2-mod-security2, the ModSecurity module used on Debian and Ubuntu servers, including ISPConfig servers
- Apache mod_remoteip, if your server sits behind a proxy or CDN
- WordPress: Brute Force Attacks and WordPress XML-RPC API
- WooCommerce Store API
- Google: how HTTP status codes affect crawling
Prerequisites
Section titled “Prerequisites”- Administrator access to Plesk, root access to WHM, or root access to an ISPConfig or plain Apache server.
- ModSecurity 2.9 running inside Apache. These rules use actions that ModSecurity 3 (libModSecurity) does not implement, so they are not for the nginx connector.
- On Plesk, check that Run rules on is set to Apache (ModSecurity 2.9) and that each site has Proxy mode enabled under Apache & nginx Settings, because Plesk runs ModSecurity 2.9 only for sites with proxy mode enabled.
- On cPanel & WHM, the ModSecurity Apache module is installed through EasyApache 4.
- A persistent data directory (
SecDataDir) that the Apache user can write to. This is where ModSecurity stores the counters, in DBM (small database) files. Confirm which value is in effect with the command below. If it prints more than one line, the one loaded last wins; ignore any line frommodsecurity.conf-recommended, a template that is never loaded. On Debian and Ubuntu,/etc/modsecurity/modsecurity.conf(copied from the recommended file) setsSecDataDir /tmp/, which overrides the module’s own default. Use a dedicated directory owned by the Apache user instead, as shown in Install on ISPConfig or a plain Apache server.
grep -Rn SecDataDir /etc/apache2 /etc/httpd /etc/modsecurity 2>/dev/null- Apache must see each visitor’s real address. Look at a site’s access log: if every line shows the same address, a proxy or CDN is in front of Apache and you need
mod_remoteipconfigured first, or every visitor will share a single counter. - Request body inspection for the rules that read form fields: the Tradeoff or Thorough preset on Plesk, or
SecRequestBodyAccess Onelsewhere. The rules that need it are marked.
How the rules are organised
Section titled “How the rules are organised”Rule IDs. Every rule here uses an ID between 41000 and 41099. ModSecurity reserves IDs 1 to 99,999 for local rules, meaning rules you write for your own server, so they cannot collide with published rule sets. If you already use some of those numbers for your own rules, renumber these; Apache refuses to load two rules with the same ID in the same configuration.
Phases and chains. ModSecurity checks each request in stages called phases. Phase 1 runs as soon as the request headers arrive, before any submitted form has been read. Phase 2 runs after the form (the request body) has been read, so only phase 2 rules can look at submitted fields such as a password or a WordPress action value. Within a phase, rules run in the order they appear in the file. A rule that ends in chain has a second, indented line underneath it: that line is an extra condition, and the rule only acts when both lines match.
Two kinds of response. Rules that refuse something outright answer 403 Forbidden. Rules that limit how often something can happen answer 429 Too Many Requests, the status code that tells a client it is sending too much and should try again later.
How the counting rules work. Each rate limit follows the same four steps:
- A flag rule recognises the request you care about (a login attempt, a checkout) and sets a transaction variable, a value that exists for this one request only.
- A count rule adds one to a counter held for the visitor’s address.
- A window rule starts a timer the first time the counter is set. When the timer runs out, the counter is cleared and the address starts again from zero.
- A refuse rule answers 429 to any flagged request once the counter is over the limit.
The counters live in a collection, which is ModSecurity’s name for a small record it saves to disk between requests. Because it is stored in the SecDataDir directory, every Apache process shares the same count.
Limits are per address. Each counter belongs to one IP address, and each IPv6 address counts on its own. A per-address limit is aimed at floods from a single source. It is one layer among several: keep your payment provider’s fraud screening switched on, and use a CAPTCHA or a login security plugin where attacks come from many addresses at once.
The numbers are examples. Every limit and window below is an illustrative starting point, not a recommendation. A small brochure site, a busy shop and a shop running a flash sale need very different values. Trial the rules in detection mode, look at what real visitors do, and tune the numbers from that (see Tune the limits).
Start here: the per-address record
Section titled “Start here: the per-address record”Every counting rule needs this line, and it must come before them. It loads (or creates) the stored record for the visitor’s address at the start of each request.
# Required by every counting rule below. Load the stored per-address record.SecAction "id:41000,phase:1,pass,nolog,setuid:%{REMOTE_ADDR}"The record is kept in ModSecurity’s user collection rather than the more familiar ip collection, and it is opened with setuid, the action ModSecurity provides for that collection. If it is opened with initcol:user instead, requests from one address that arrive at the same moment overwrite each other’s counts, so a parallel flood is undercounted. ModSecurity 2.9 honours only the first initcol for a collection in each request. General rule sets, the OWASP Core Rule Set among them, open the ip collection themselves under their own key, and when a vendor rule set loads first, counters placed in ip are stored under that rule set’s key rather than the address alone. Using user, which those rule sets do not open, keeps these counters keyed on the address whatever order the rules load in. Troubleshooting shows how to confirm nothing else on your server opens it.
Keep passwords out of the audit log
Section titled “Keep passwords out of the audit log”ModSecurity writes two kinds of log. The Apache error log gets one line for each rule that matches. The audit log is a separate, more detailed log (its location is set by the SecAuditLog directive; on Plesk you read it through the Logs Archive link on the Web Application Firewall (ModSecurity) page) that can include the full request headers and the submitted form. For a WordPress login that means the password a visitor typed, and for a REST API call using an application password it means the Authorization header, which carries the credential.
These rules keep those values out of the audit log. They change nothing about how requests are handled. Put them near the top, straight after the per-address record.
# Mask the Authorization header (application passwords, HTTP basic auth).SecAction "id:41001,phase:1,pass,nolog,sanitiseRequestHeader:Authorization"
# Leave the submitted form out of the audit log for login, XML-RPC,# My Account and Store API checkout submissions.SecRule REQUEST_METHOD "@streq POST" \ "id:41003,phase:1,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx (?:/wp-login\.php|/xmlrpc\.php|/my-account(?:/|$)|/wc/store(?:/v[0-9]+)?/checkout)" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,ctl:auditLogParts=-CI"
# Mask WordPress and WooCommerce password fields anywhere else. Needs request body inspection.SecAction "id:41002,phase:2,pass,nolog,sanitiseArg:pwd,sanitiseArg:pass1,sanitiseArg:pass2,sanitiseArg:password,sanitiseArg:password_current,sanitiseArg:password_1,sanitiseArg:password_2,sanitiseArg:post_password,sanitiseArg:account_password"Rule 41003 removes the request body (audit log parts C and I) from the audit log entry for submissions that carry passwords. Because it runs in phase 1, it takes effect before any rule set can block the request in phase 2. If your WooCommerce account page is not at /my-account/, change that part of the pattern to match its address. Rule 41002 replaces the named password fields with asterisks on any other page that submits them.
Both rules cover the audit log only. If a rule from your main rule set matches a password field, the error log line for that match can still quote the matched value, so treat the error log as sensitive too.
If you also want login session cookies masked, add sanitiseRequestHeader:Cookie to rule 41001. The trade-off is that the audit log can then no longer help you diagnose cookie problems.
Problem 1: login floods
Section titled “Problem 1: login floods”What it looks like. A script submits username and password guesses to wp-login.php, or to the My Account login form on a WooCommerce shop, hundreds or thousands of times from one address. Every attempt makes WordPress load fully and check a password hash, which is deliberately slow, so a flood of guesses costs real CPU even when none of them succeed.
What the rules do. They count login submissions per address and answer 429 once an address goes over the limit. A person who mistypes their password a few times never comes near it. In this example the limit is 10 attempts in a 10 minute window.
# Flag a submission to wp-login.php (login, lost password, reset password).SecRule REQUEST_METHOD "@streq POST" \ "id:41010,phase:2,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME "@contains /wp-login.php" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_login=1"
# Flag a WooCommerce My Account login. Needs request body inspection.SecRule &ARGS_POST:woocommerce-login-nonce "@gt 0" \ "id:41011,phase:2,pass,nolog,t:none,setvar:tx.local_login=1"
# Count flagged attempts for this address.SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41012,phase:2,pass,nolog,t:none,setvar:user.local_login_hits=+1"
# Start the 600 second window on the first attempt.SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41013,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_LOGIN_HITS "@eq 1" \ "t:none,expirevar:user.local_login_hits=600"
# Refuse further attempts once the address is over 10 in the window.SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41014,phase:2,deny,status:429,log,t:none,msg:'Login rate limit exceeded',logdata:'%{user.local_login_hits} attempts in window',chain" SecRule USER:LOCAL_LOGIN_HITS "@gt 10" "t:none"Line by line: the flag rules set a transaction variable (tx.local_login). The t:urlDecodeUni and t:normalizePath transformations make sure the path is compared in its decoded, tidied form, and @contains matches wp-login.php whether WordPress is installed at the top of the site or in a subfolder. The WooCommerce rule recognises the login form by its nonce, the one-time security token field WooCommerce adds to the form. All five rules run in phase 2, after the form has been read, so both kinds of login are handled by one counter and one refusal.
This does not replace a strong password, two-factor authentication or a login-limiting plugin. It stops the flood at the web server, before WordPress spends any effort on it.
Problem 2: XML-RPC abuse
Section titled “Problem 2: XML-RPC abuse”What it looks like. xmlrpc.php is WordPress’s older remote-publishing interface. Attackers like it for two reasons. It accepts password guesses, and its system.multicall method lets a single request carry hundreds of them. Its pingback.ping method can be used to make your site send requests to a third party, turning it into a participant in someone else’s attack.
First decide whether you need it. Most sites do not. Some plugins and mobile apps still connect over XML-RPC, so check before you block it. Choose one of the two options below, not both.
Option A: block XML-RPC completely
Section titled “Option A: block XML-RPC completely”Use this when no site on the server needs XML-RPC. Because a server-wide rule applies to every site, this blocks it everywhere.
# Refuse every request to xmlrpc.php on this server.SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "id:41020,phase:1,deny,status:403,log,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,msg:'XML-RPC is disabled on this server'"On Plesk, if only some sites should lose XML-RPC, WP Toolkit’s Security measures include Block access to xmlrpc.php, which you can apply one site at a time instead.
Option B: keep XML-RPC, limit it and block the two abused methods
Section titled “Option B: keep XML-RPC, limit it and block the two abused methods”Use this when some sites depend on XML-RPC. The rules work together:
- Rule 41021 tells ModSecurity to read and parse requests to
xmlrpc.phpas XML, even if request body inspection is otherwise off. - Rule 41022 reads the name of the method being called and refuses the two that are abused.
- Rule 41023 refuses an XML-RPC request whose body ModSecurity could not read as XML, so a request cannot reach WordPress without its method having been checked. ModSecurity usually answers such a request itself with
500 Internal Server Errorbefore this rule runs, so the rule is a safety net. - Rules 41024 to 41027 count every XML-RPC submission per address, so guesses sent one per request are limited too. In this example the limit is 30 requests in 5 minutes, higher than the login limit so that legitimate apps and plugins that talk to XML-RPC are not caught.
# Parse xmlrpc.php requests as XML so the method name can be inspected.SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "id:41021,phase:1,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,ctl:requestBodyAccess=On,ctl:requestBodyProcessor=XML"
# Refuse the batch-call and pingback methods.SecRule XML:/methodCall/methodName/text() "@rx ^(?:system\.multicall|pingback\.ping)$" \ "id:41022,phase:2,deny,status:403,log,t:none,t:trim,msg:'Blocked XML-RPC method',logdata:'%{MATCHED_VAR}'"
# Refuse XML-RPC requests whose body could not be parsed.SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "id:41023,phase:2,deny,status:403,log,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,msg:'Unreadable XML-RPC request body',chain" SecRule REQBODY_ERROR "!@eq 0" "t:none"
# Flag every XML-RPC submission.SecRule REQUEST_METHOD "@streq POST" \ "id:41024,phase:2,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_xmlrpc=1"
# Count flagged submissions for this address.SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41025,phase:2,pass,nolog,t:none,setvar:user.local_xmlrpc_hits=+1"
# Start the 300 second window on the first submission.SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41026,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_XMLRPC_HITS "@eq 1" \ "t:none,expirevar:user.local_xmlrpc_hits=300"
# Refuse once the address is over 30 in the window.SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41027,phase:2,deny,status:429,log,t:none,msg:'XML-RPC rate limit exceeded',logdata:'%{user.local_xmlrpc_hits} requests in window',chain" SecRule USER:LOCAL_XMLRPC_HITS "@gt 30" "t:none"Blocking pingback.ping also stops your sites receiving pingbacks from other blogs, which for most sites today is mainly a source of spam. During the trial period, check the log for rule 41022 and 41027 hits from services you actually use, and raise the limit in rule 41027 if a legitimate integration reaches it.
Problem 3: filter and add-to-cart floods
Section titled “Problem 3: filter and add-to-cart floods”What it looks like. A WooCommerce shop’s product filters (colour, size, price range, rating) and add-to-cart links produce URLs that cannot be served from a page cache, because each combination is different and add-to-cart requests change the visitor’s cart. Bots that request thousands of these combinations, or replay ?add-to-cart= links over and over, force the shop to build a fresh page every time. It is one of the cheapest ways to exhaust a shop’s CPU.
What the rules do. They count requests that carry WooCommerce filter or add-to-cart parameters, and answer 429 when one address goes over the limit. They also count the background add-to-cart calls that themes and cart pages make. These are AJAX requests (requests a page sends in the background without reloading), sent either to WooCommerce’s own wc-ajax address or to WordPress’s admin-ajax.php, and calls to the Store API, the interface WooCommerce’s newer block-based cart and checkout pages use. In this example the limit is 30 such requests in 2 minutes. Tune it to your own shop.
# Flag requests carrying WooCommerce filter or add-to-cart query parameters.# filter_action is left out: WordPress admin list screens use it.SecRule ARGS_GET_NAMES "@rx ^(?:add-to-cart|filter_(?!action$).+|query_type_.+|min_price|max_price|rating_filter)$" \ "id:41030,phase:1,pass,nolog,t:none,t:lowercase,setvar:tx.local_shop=1"
# Flag the wc-ajax add-to-cart call.SecRule ARGS_GET:wc-ajax "@streq add_to_cart" \ "id:41031,phase:1,pass,nolog,t:none,t:lowercase,setvar:tx.local_shop=1"
# Flag Store API add-to-cart and batch calls (pretty and plain permalinks).SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx /wc/store(?:/v[0-9]+)?/(?:cart/add-item|batch)" \ "id:41032,phase:1,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_shop=1"
# Flag the admin-ajax.php add-to-cart call. Needs request body inspection.SecRule REQUEST_FILENAME "@contains /wp-admin/admin-ajax.php" \ "id:41033,phase:2,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,chain" SecRule ARGS:action "@streq woocommerce_add_to_cart" \ "t:none,t:lowercase,setvar:tx.local_shop=1"
# Count flagged requests for this address.SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41034,phase:2,pass,nolog,t:none,setvar:user.local_shop_hits=+1"
# Start the 120 second window on the first request.SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41035,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_SHOP_HITS "@eq 1" \ "t:none,expirevar:user.local_shop_hits=120"
# Refuse once the address is over 30 in the window.SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41036,phase:2,deny,status:429,log,t:none,msg:'Shop request rate limit exceeded',logdata:'%{user.local_shop_hits} requests in window',chain" SecRule USER:LOCAL_SHOP_HITS "@gt 30" "t:none"“Pretty and plain permalinks” refers to the URL style chosen in WordPress under Settings > Permalinks: the Store API address looks different in each, and rule 41032 recognises both. The counting rules run in phase 2 so that the admin-ajax.php call, whose action value usually arrives in the submitted form, is counted as well.
The parameter names in rule 41030 are the ones WooCommerce’s own filter widgets and blocks use. If your shop uses a third-party filter plugin with its own parameter names, add them to the list. Because the flag is a single yes-or-no value per request, a URL with five filter parameters still counts once. Cart blocks also use the Store API batch address for ordinary cart updates, which is why it is counted here, under the more generous shop limit.
Optional: refuse add-to-cart requests sent to the cart or checkout page
Section titled “Optional: refuse add-to-cart requests sent to the cart or checkout page”WooCommerce’s own add-to-cart links point at product and shop pages. Bots often send ?add-to-cart= to the cart or checkout page instead, because those pages are never cached. This rule refuses that pattern outright. It assumes your cart and checkout pages are at the default /cart/ and /checkout/ addresses; change the pattern if yours differ.
# Refuse add-to-cart requests sent to the cart or checkout page.SecRule ARGS_GET_NAMES "@streq add-to-cart" \ "id:41037,phase:1,deny,status:403,log,t:none,t:lowercase,msg:'Add-to-cart request sent to the cart or checkout page',chain" SecRule REQUEST_FILENAME "@rx /(?:cart|checkout)/?$" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase"Leave this rule out if you use direct checkout links, such as a “Buy now” link in an email, advert or landing page that points at /checkout/?add-to-cart=123. Those links would be refused.
Problem 4: checkout and coupon abuse
Section titled “Problem 4: checkout and coupon abuse”What it looks like. Card testers use a shop to find out which stolen card numbers still work, submitting order after order with small amounts, or adding card after card through My Account > Payment methods, which checks a card without creating an order. A related pattern guesses coupon codes one after another. Both leave you with failed orders, payment provider fees and, if it continues, a payment account under review.
What the rules do. They count order submissions, saved-card attempts and coupon attempts per address, through the classic checkout, the wc-ajax and admin-ajax.php background calls, and the Store API used by the checkout block, and answer 429 over the limit. Only submissions are counted, not page views, so browsing the checkout page is unaffected. In this example the limit is 6 attempts in a 15 minute window, which leaves a genuine customer room to retry a declined card a few times. The counting runs in phase 2, and this group needs request body inspection.
# Flag a wc-ajax checkout submission or coupon attempt.SecRule REQUEST_METHOD "@streq POST" \ "id:41040,phase:1,pass,nolog,t:none,chain" SecRule ARGS_GET:wc-ajax "@rx ^(?:checkout|apply_coupon)$" \ "t:none,t:lowercase,setvar:tx.local_checkout=1"
# Flag a Store API checkout submission or coupon attempt.SecRule REQUEST_METHOD "@streq POST" \ "id:41041,phase:1,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx /wc/store(?:/v[0-9]+)?/(?:checkout|cart/apply-coupon)" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_checkout=1"
# Flag the admin-ajax.php checkout and coupon calls. Needs request body inspection.SecRule REQUEST_FILENAME "@contains /wp-admin/admin-ajax.php" \ "id:41042,phase:2,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,chain" SecRule ARGS:action "@rx ^woocommerce_(?:checkout|apply_coupon)$" \ "t:none,t:lowercase,setvar:tx.local_checkout=1"
# Flag any classic checkout form or Add payment method submission. Needs request body inspection.SecRule &ARGS_POST:woocommerce-process-checkout-nonce|&ARGS_POST:woocommerce-add-payment-method-nonce "@gt 0" \ "id:41043,phase:2,pass,nolog,t:none,setvar:tx.local_checkout=1"
# Count flagged submissions for this address.SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41044,phase:2,pass,nolog,t:none,setvar:user.local_checkout_hits=+1"
# Start the 900 second window on the first submission.SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41045,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_CHECKOUT_HITS "@eq 1" \ "t:none,expirevar:user.local_checkout_hits=900"
# Refuse once the address is over 6 in the window.SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41046,phase:2,deny,status:429,log,t:none,msg:'Checkout rate limit exceeded',logdata:'%{user.local_checkout_hits} attempts in window',chain" SecRule USER:LOCAL_CHECKOUT_HITS "@gt 6" "t:none"Rule 41043 recognises the classic checkout and the Add payment method form by their nonce fields, so it counts them whichever address they are sent to. Some payment gateways add their own wc-ajax actions, for example for express payment buttons. To find them, open your browser’s developer tools on the Network tab during a test purchase and note the wc-ajax= values that are sent; add those action names to the pattern in rule 41040.
Treat this as one layer. Keep your payment provider’s fraud screening switched on, and consider a CAPTCHA or similar challenge on checkout if card testing is a recurring problem for your shop.
Problem 5: scanner noise
Section titled “Problem 5: scanner noise”What it looks like. Automated scanners request the same list of paths on every site they find: environment files such as .env, version-control folders such as .git/, copies of wp-config.php left behind by an editor or a backup, database dumps, phpinfo.php, and test tools left inside a vendor/ folder. On a WordPress site this costs more than it seems, because WordPress’s standard rewrite rules send every request for a file that does not exist to index.php. Each probe therefore loads WordPress in full just to produce a “not found” page.
What the rules do. The first rule refuses requests for these file types at Apache, before WordPress is involved. The second refuses a few well-known attack tools that identify themselves in their user agent.
# Refuse requests for secrets, repositories, config copies, dumps and test tools.SecRule REQUEST_FILENAME "@rx (?:/\.(?:env(?:\.[a-z0-9_-]+)?|git|svn|hg|ds_store)(?:/|$)|/wp-config\.php.+|/phpinfo\.php$|/vendor/phpunit/|\.(?:sql|sql\.gz|sql\.zip|bak|old|orig|save|swp)$)" \ "id:41050,phase:1,deny,status:403,log,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,msg:'Request for a sensitive or backup file'"
# Refuse well-known scanning tools that identify themselves.SecRule REQUEST_HEADERS:User-Agent "@pm sqlmap nikto masscan zgrab nuclei" \ "id:41051,phase:1,deny,status:403,log,t:none,msg:'Known scanning tool'"Rule 41050 is there to stop probes wasting your server’s effort. It is not a substitute for removing a stray backup or dump from the web root: if such a file exists, delete it or move it outside the site’s document root. This matters most on Plesk, where nginx sits in front of Apache and, with Serve static files directly by nginx switched on under Apache & nginx Settings, serves existing files with extensions on its static list (such as .gz, .zip and .txt) straight from disk. Those requests never reach Apache, so no ModSecurity 2.9 rule sees them. If you want nginx to pass those extensions to Apache instead, remove them from the static file list in the same settings.
Before you enable rule 41050, make sure none of your sites deliberately serves a file with one of these endings (for example a downloadable .sql sample). Rule 41051 is cheap noise reduction, and the OWASP Core Rule Set already includes a similar check, so you can leave it out if you run that rule set. The rate limits above do the heavier work.
Problem 6: crawler load
Section titled “Problem 6: crawler load”What it looks like. Commercial SEO crawlers and AI crawlers can request thousands of pages a day, including every filter combination on a shop. They are not attacks, but on a small server they compete with real visitors for CPU. The polite first step is robots.txt, which well-behaved crawlers read and obey. These rules add a firmer answer at the web server.
What the rules do. The first rule refuses a list of named commercial SEO crawlers with 403. If you use one of these services to audit your own site, remove its name from the list. The remaining rules rate-limit AI crawlers and AI assistant fetchers per address with 429, which slows them down without shutting them out. In this example the limit is 60 requests a minute per address.
# Refuse named commercial SEO crawlers.SecRule REQUEST_HEADERS:User-Agent "@pm AhrefsBot SemrushBot MJ12bot DotBot BLEXBot DataForSeoBot" \ "id:41060,phase:1,deny,status:403,log,t:none,msg:'SEO crawler refused'"
# Flag AI crawlers and AI assistant fetchers.SecRule REQUEST_HEADERS:User-Agent "@pm GPTBot OAI-SearchBot ChatGPT-User ClaudeBot Claude-User Claude-SearchBot PerplexityBot Perplexity-User CCBot Bytespider Amazonbot meta-externalagent" \ "id:41061,phase:1,pass,nolog,t:none,setvar:tx.local_ai=1"
# Count flagged requests for this address.SecRule TX:LOCAL_AI "@eq 1" \ "id:41062,phase:1,pass,nolog,t:none,setvar:user.local_ai_hits=+1"
# Start the 60 second window on the first request.SecRule TX:LOCAL_AI "@eq 1" \ "id:41063,phase:1,pass,nolog,t:none,chain" SecRule USER:LOCAL_AI_HITS "@eq 1" \ "t:none,expirevar:user.local_ai_hits=60"
# Refuse once the address is over 60 in the window.SecRule TX:LOCAL_AI "@eq 1" \ "id:41064,phase:1,deny,status:429,log,t:none,msg:'AI crawler rate limit exceeded',logdata:'%{user.local_ai_hits} requests in window',chain" SecRule USER:LOCAL_AI_HITS "@gt 60" "t:none"Search engine crawlers such as Googlebot and Bingbot are deliberately not on either list.
Optional: refuse AI training crawlers
Section titled “Optional: refuse AI training crawlers”If you do not want your content used to train AI models, you can refuse the crawlers whose stated purpose is collecting training data, while still allowing the search and assistant fetchers that can send visitors to your site. Place this rule before rule 41061.
# Refuse crawlers that collect AI training data.SecRule REQUEST_HEADERS:User-Agent "@pm GPTBot ClaudeBot CCBot meta-externalagent" \ "id:41065,phase:1,deny,status:403,log,t:none,msg:'AI training crawler refused'"Add the same names to robots.txt with Disallow: / as well, so the crawlers that honour it stop asking.
Plan a detection trial before you install
Section titled “Plan a detection trial before you install”A new rate limit should watch before it blocks. Run the rules for at least a few days of normal traffic, ideally including a busy period such as a newsletter or a sale, and see who they would have refused. Decide which way you will trial them before you follow the install steps, because on some servers the engine is already enforcing and new rules block from the moment they load.
If the server is not yet enforcing anything, put the whole firewall into detection mode:
- Plesk: set the web application firewall mode to Detection only in Tools & Settings > Web Application Firewall (ModSecurity). A domain cannot be set higher than the server, so this puts every site into detection.
- cPanel & WHM: in WHM > Security Center > ModSecurity Configuration, set Rules Engine to Process the rules in verbose mode, but do not execute disruptive actions.
- ISPConfig or plain Apache: set
SecRuleEngine DetectionOnlyin the main ModSecurity configuration file (see the install steps below for which file that is), or add it to one site’s Apache Directives field in ISPConfig to trial a single site.
If your server already enforces a rule set you do not want to weaken, trial only the new rules instead. In your copy of the rules, change deny to pass in each refusing rule you use (41014; 41020, or 41022, 41023 and 41027; 41036 and 41037; 41046; 41050 and 41051; 41060, 41064 and 41065) and leave log in place. The counters still count and each would-be refusal is still logged, but nothing is blocked.
Install on Plesk
Section titled “Install on Plesk”Plesk applies custom directives server-wide, to every site where the web application firewall is set to On or Detection only.
- Log in to Plesk as an administrator and go to Tools & Settings > Web Application Firewall (ModSecurity) (under Security).
- Open the Settings tab and confirm Run rules on is set to Apache (ModSecurity 2.9).
- Leave your chosen rule set selected.
- In the predefined sets of parameters, choose Tradeoff or Thorough, not Fast, if you will use any rule marked as needing request body inspection. Fast does not read submitted forms.
- Paste the rules into the Custom directives field. Put the per-address record (41000) and the password rules (41001, 41003, 41002) first, then the groups you want, in the order shown in this guide.
- Click OK or Apply to save. Plesk applies the change to its web server configuration.
- Check each site you want protected: Websites & Domains > the domain > Web Application Firewall must be On or Detection only, not Off.
The same tab also offers Custom rule set, which accepts an uploaded archive or .conf file. That option replaces the rule set you have selected rather than adding to it, so do not use it for these rules unless you intend to run them on their own or package them with a complete rule set of your own.
Install on cPanel & WHM
Section titled “Install on cPanel & WHM”cPanel & WHM stores your own rules in a user rules file that Apache loads alongside the vendor rule sets.
- Log in to WHM as root and go to Security Center > ModSecurity Tools.
- Open Rules List and click Edit Rules. (Add Rule takes one rule at a time; Edit Rules lets you paste the whole set at once.)
- Paste the rules into the Rules text box, per-address record first, in the order shown in this guide, and click Save.
- Click Deploy and Restart Apache to apply the change.
ModSecurity can also be switched off for individual domains. If a site should not get these rules, see How to Enable or Disable ModSecurity in cPanel.
Install on ISPConfig or a plain Apache server
Section titled “Install on ISPConfig or a plain Apache server”ISPConfig does not manage ModSecurity itself; it uses the ModSecurity package installed on the server. The simplest and most reliable approach is a single include file that applies to every site.
On Debian and Ubuntu, the package ships a recommended configuration but does not load it until you copy it into place. Until then, the rules engine and request body inspection stay off and none of these rules run. The recommended file sets SecRuleEngine DetectionOnly and SecRequestBodyAccess On, which is exactly what you want for the trial, but it also sets SecDataDir /tmp/. These commands copy the file only if it does not exist yet, point SecDataDir at a dedicated directory owned by the Apache user, and show the resulting settings:
cp -n /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.confinstall -d -o www-data -g www-data -m 750 /var/cache/modsecuritysed -i 's|^SecDataDir .*|SecDataDir /var/cache/modsecurity|' /etc/modsecurity/modsecurity.confgrep -E '^(SecRuleEngine|SecRequestBodyAccess|SecDataDir)' /etc/modsecurity/modsecurity.confThen create /etc/modsecurity/wordpress-local-rules.conf and paste in the rules, per-address record first. The module loads every *.conf file in /etc/modsecurity/.
On Fedora and RHEL-family systems, the main file is /etc/httpd/conf.d/mod_security.conf, and it sets SecRuleEngine On. The rules therefore block from the moment Apache reloads. Before you add them, either change that line to SecRuleEngine DetectionOnly, or make the deny to pass change described in Plan a detection trial before you install. Then create /etc/httpd/modsecurity.d/local_rules/wordpress-local-rules.conf and paste in the rules, per-address record first.
Check the configuration and reload Apache. The reload only runs if the configuration check passes, so a typing mistake leaves Apache running on its previous configuration until you fix it.
On Debian and Ubuntu:
apachectl -t && systemctl reload apache2On Fedora and RHEL-family systems:
apachectl -t && systemctl reload httpdPer site on ISPConfig. To protect only some sites, paste the rules into Sites > Websites > the website > Options > Apache Directives instead. ISPConfig writes that field into the site’s Apache configuration (its virtual host), so a manual edit to the virtual host file would be overwritten; the field is the place to keep them. Use either the server-wide file or the per-site field, never both. If the rules load twice, Apache may not report an error, but every counter then goes up twice per request, which silently halves every limit.
Review the trial and switch on
Section titled “Review the trial and switch on”Matches are written to the Apache error log with the rule ID. This counts how often each rule matched; replace the path with the error log for your platform and site:
grep -o '\[id "410[0-9][0-9]"\]' /var/log/apache2/error.log | sort | uniq -cWhere to find the error log:
- Plesk:
/var/www/vhosts/system/yourdomain.com/logs/error_log, or Websites & Domains > the domain > Logs in the Plesk interface. - cPanel & WHM:
/usr/local/apache/logs/error_log. - ISPConfig:
/var/log/ispconfig/httpd/yourdomain.com/error.log. - Debian and Ubuntu:
/var/log/apache2/error.log, unless the site has its own error log. - Fedora and RHEL-family:
/var/log/httpd/error_log.
Replace yourdomain.com with your own domain. Look for addresses you recognise, such as your own office, an uptime monitor or a partner’s integration, and for any rule that matches far more often than you expected.
When you are satisfied, switch the mode back to On (Plesk), Process the rules (WHM Rules Engine) or SecRuleEngine On, or change pass back to deny in the refusing rules, then save or reload.
Confirm the rules are enforcing
Section titled “Confirm the rules are enforcing”Run these from your own computer, replacing yourdomain.com with one of your sites. Each should print the status code shown in the comment.
# Sensitive file probe: expect 403curl -s -o /dev/null -w '%{http_code}\n' https://yourdomain.com/.env
# XML-RPC batch call (Option B): expect 403curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: text/xml' \ --data '<?xml version="1.0"?><methodCall><methodName>system.multicall</methodName><params></params></methodCall>' \ https://yourdomain.com/xmlrpc.php
# Ordinary XML-RPC call (Option B): expect 200curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: text/xml' \ --data '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName><params></params></methodCall>' \ https://yourdomain.com/xmlrpc.phpTo test the login limit, send more attempts than the limit with a made-up username. The first ten return 200 (WordPress shows its login page with an error) and the rest return 429:
for i in $(seq 1 12); do curl -s -o /dev/null -w '%{http_code}\n' -d 'log=ratelimit-test&pwd=not-a-password' https://yourdomain.com/wp-login.phpdoneTo test the shop limit, request a page with a changing price filter more times than the limit. With the example values you get 30 normal responses (usually 200), then 429 for the rest:
for i in $(seq 1 32); do curl -s -o /dev/null -w '%{http_code}\n' "https://yourdomain.com/?min_price=$i"doneEach test locks your own address out of that part of the site for the rest of the window, so run it from a connection you can spare, or be ready to wait for the window to expire.
Tune the limits
Section titled “Tune the limits”Each rate limit has two numbers: the limit in the refusing rule (@gt 10 in 41014, @gt 30 in 41027 and 41036, @gt 6 in 41046, @gt 60 in 41064) and the window in the window rule before it (expirevar:...=600, =300, =120, =900, =60, in seconds). The window starts at an address’s first counted request and the counter clears when it ends.
- Raise the limit when real visitors are being caught: for example a filter plugin that fires several requests per click, or a shop whose customers compare many filter combinations.
- Lengthen the window when attacks arrive slowly and steadily rather than in bursts.
- Change one number at a time, and base the change on what the log shows real visitors doing, not on a guess.
Every counting rule also writes to the collection files in SecDataDir. Those files grow over time and do not reclaim space on their own, so include them in your routine server maintenance.
All the rules in one block
Section titled “All the rules in one block”This block contains the per-address record, the password rules and every group above, in the right order, using Option B for XML-RPC. It leaves out the three optional rules: Option A (41020), the cart and checkout refusal (41037) and the AI training crawler refusal (41065). Add any of those from their own sections if you want them, and delete any group you do not need. Every limit is an example to tune.
# ---- Per-address record ----SecAction "id:41000,phase:1,pass,nolog,setuid:%{REMOTE_ADDR}"
# ---- Keep passwords out of the audit log ----SecAction "id:41001,phase:1,pass,nolog,sanitiseRequestHeader:Authorization"SecRule REQUEST_METHOD "@streq POST" \ "id:41003,phase:1,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx (?:/wp-login\.php|/xmlrpc\.php|/my-account(?:/|$)|/wc/store(?:/v[0-9]+)?/checkout)" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,ctl:auditLogParts=-CI"SecAction "id:41002,phase:2,pass,nolog,sanitiseArg:pwd,sanitiseArg:pass1,sanitiseArg:pass2,sanitiseArg:password,sanitiseArg:password_current,sanitiseArg:password_1,sanitiseArg:password_2,sanitiseArg:post_password,sanitiseArg:account_password"
# ---- Login floods (example: 10 in 600 seconds) ----SecRule REQUEST_METHOD "@streq POST" \ "id:41010,phase:2,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME "@contains /wp-login.php" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_login=1"SecRule &ARGS_POST:woocommerce-login-nonce "@gt 0" \ "id:41011,phase:2,pass,nolog,t:none,setvar:tx.local_login=1"SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41012,phase:2,pass,nolog,t:none,setvar:user.local_login_hits=+1"SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41013,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_LOGIN_HITS "@eq 1" \ "t:none,expirevar:user.local_login_hits=600"SecRule TX:LOCAL_LOGIN "@eq 1" \ "id:41014,phase:2,deny,status:429,log,t:none,msg:'Login rate limit exceeded',logdata:'%{user.local_login_hits} attempts in window',chain" SecRule USER:LOCAL_LOGIN_HITS "@gt 10" "t:none"
# ---- XML-RPC, Option B (example: 30 in 300 seconds) ----SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "id:41021,phase:1,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,ctl:requestBodyAccess=On,ctl:requestBodyProcessor=XML"SecRule XML:/methodCall/methodName/text() "@rx ^(?:system\.multicall|pingback\.ping)$" \ "id:41022,phase:2,deny,status:403,log,t:none,t:trim,msg:'Blocked XML-RPC method',logdata:'%{MATCHED_VAR}'"SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "id:41023,phase:2,deny,status:403,log,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,msg:'Unreadable XML-RPC request body',chain" SecRule REQBODY_ERROR "!@eq 0" "t:none"SecRule REQUEST_METHOD "@streq POST" \ "id:41024,phase:2,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME "@contains /xmlrpc.php" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_xmlrpc=1"SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41025,phase:2,pass,nolog,t:none,setvar:user.local_xmlrpc_hits=+1"SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41026,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_XMLRPC_HITS "@eq 1" \ "t:none,expirevar:user.local_xmlrpc_hits=300"SecRule TX:LOCAL_XMLRPC "@eq 1" \ "id:41027,phase:2,deny,status:429,log,t:none,msg:'XML-RPC rate limit exceeded',logdata:'%{user.local_xmlrpc_hits} requests in window',chain" SecRule USER:LOCAL_XMLRPC_HITS "@gt 30" "t:none"
# ---- Filter and add-to-cart floods (example: 30 in 120 seconds) ----SecRule ARGS_GET_NAMES "@rx ^(?:add-to-cart|filter_(?!action$).+|query_type_.+|min_price|max_price|rating_filter)$" \ "id:41030,phase:1,pass,nolog,t:none,t:lowercase,setvar:tx.local_shop=1"SecRule ARGS_GET:wc-ajax "@streq add_to_cart" \ "id:41031,phase:1,pass,nolog,t:none,t:lowercase,setvar:tx.local_shop=1"SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx /wc/store(?:/v[0-9]+)?/(?:cart/add-item|batch)" \ "id:41032,phase:1,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_shop=1"SecRule REQUEST_FILENAME "@contains /wp-admin/admin-ajax.php" \ "id:41033,phase:2,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,chain" SecRule ARGS:action "@streq woocommerce_add_to_cart" \ "t:none,t:lowercase,setvar:tx.local_shop=1"SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41034,phase:2,pass,nolog,t:none,setvar:user.local_shop_hits=+1"SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41035,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_SHOP_HITS "@eq 1" \ "t:none,expirevar:user.local_shop_hits=120"SecRule TX:LOCAL_SHOP "@eq 1" \ "id:41036,phase:2,deny,status:429,log,t:none,msg:'Shop request rate limit exceeded',logdata:'%{user.local_shop_hits} requests in window',chain" SecRule USER:LOCAL_SHOP_HITS "@gt 30" "t:none"
# ---- Checkout and coupon abuse (example: 6 in 900 seconds) ----SecRule REQUEST_METHOD "@streq POST" \ "id:41040,phase:1,pass,nolog,t:none,chain" SecRule ARGS_GET:wc-ajax "@rx ^(?:checkout|apply_coupon)$" \ "t:none,t:lowercase,setvar:tx.local_checkout=1"SecRule REQUEST_METHOD "@streq POST" \ "id:41041,phase:1,pass,nolog,t:none,chain" SecRule REQUEST_FILENAME|ARGS_GET:rest_route "@rx /wc/store(?:/v[0-9]+)?/(?:checkout|cart/apply-coupon)" \ "t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,setvar:tx.local_checkout=1"SecRule REQUEST_FILENAME "@contains /wp-admin/admin-ajax.php" \ "id:41042,phase:2,pass,nolog,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,chain" SecRule ARGS:action "@rx ^woocommerce_(?:checkout|apply_coupon)$" \ "t:none,t:lowercase,setvar:tx.local_checkout=1"SecRule &ARGS_POST:woocommerce-process-checkout-nonce|&ARGS_POST:woocommerce-add-payment-method-nonce "@gt 0" \ "id:41043,phase:2,pass,nolog,t:none,setvar:tx.local_checkout=1"SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41044,phase:2,pass,nolog,t:none,setvar:user.local_checkout_hits=+1"SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41045,phase:2,pass,nolog,t:none,chain" SecRule USER:LOCAL_CHECKOUT_HITS "@eq 1" \ "t:none,expirevar:user.local_checkout_hits=900"SecRule TX:LOCAL_CHECKOUT "@eq 1" \ "id:41046,phase:2,deny,status:429,log,t:none,msg:'Checkout rate limit exceeded',logdata:'%{user.local_checkout_hits} attempts in window',chain" SecRule USER:LOCAL_CHECKOUT_HITS "@gt 6" "t:none"
# ---- Scanner noise ----SecRule REQUEST_FILENAME "@rx (?:/\.(?:env(?:\.[a-z0-9_-]+)?|git|svn|hg|ds_store)(?:/|$)|/wp-config\.php.+|/phpinfo\.php$|/vendor/phpunit/|\.(?:sql|sql\.gz|sql\.zip|bak|old|orig|save|swp)$)" \ "id:41050,phase:1,deny,status:403,log,t:none,t:urlDecodeUni,t:normalizePath,t:lowercase,msg:'Request for a sensitive or backup file'"SecRule REQUEST_HEADERS:User-Agent "@pm sqlmap nikto masscan zgrab nuclei" \ "id:41051,phase:1,deny,status:403,log,t:none,msg:'Known scanning tool'"
# ---- Crawler load (example: 60 in 60 seconds for AI crawlers) ----SecRule REQUEST_HEADERS:User-Agent "@pm AhrefsBot SemrushBot MJ12bot DotBot BLEXBot DataForSeoBot" \ "id:41060,phase:1,deny,status:403,log,t:none,msg:'SEO crawler refused'"SecRule REQUEST_HEADERS:User-Agent "@pm GPTBot OAI-SearchBot ChatGPT-User ClaudeBot Claude-User Claude-SearchBot PerplexityBot Perplexity-User CCBot Bytespider Amazonbot meta-externalagent" \ "id:41061,phase:1,pass,nolog,t:none,setvar:tx.local_ai=1"SecRule TX:LOCAL_AI "@eq 1" \ "id:41062,phase:1,pass,nolog,t:none,setvar:user.local_ai_hits=+1"SecRule TX:LOCAL_AI "@eq 1" \ "id:41063,phase:1,pass,nolog,t:none,chain" SecRule USER:LOCAL_AI_HITS "@eq 1" \ "t:none,expirevar:user.local_ai_hits=60"SecRule TX:LOCAL_AI "@eq 1" \ "id:41064,phase:1,deny,status:429,log,t:none,msg:'AI crawler rate limit exceeded',logdata:'%{user.local_ai_hits} requests in window',chain" SecRule USER:LOCAL_AI_HITS "@gt 60" "t:none"Troubleshooting
Section titled “Troubleshooting”Symptom: Apache will not reload, reporting Found another rule with the same id. Another rule in the same configuration already uses one of these IDs, or the block was pasted twice into the same file or field. Remove the duplicate copy, or renumber within the 1 to 99,999 local range.
Symptom: the rules never match, even with the test commands. Check, in this order:
- The rules are loaded. On Debian and Ubuntu,
apachectl -t -D DUMP_INCLUDESlists every file Apache reads; your rules file should be in it. On Plesk, confirm the rules are still in Custom directives; on WHM, in Rules List. - The engine is not off:
SecRuleEnginemust beOnorDetectionOnly, and on Plesk the site’s firewall mode must not be Off. - On Plesk, Proxy mode is enabled for the site in Apache & nginx Settings, and the path you are testing is not one nginx serves directly (see Problem 5).
Symptom: the rate limits never trigger, but rule 41050 does. The counters are not being stored or loaded.
- Confirm the per-address record (41000) is present and comes before the counting rules.
- Check that
SecDataDiris set to a dedicated directory owned by, and writable by, the user Apache runs as, not/tmp/(see Prerequisites). Then look in the Apache error log for errors mentioning the collection or DBM files. - Check that no other rule set on the server opens the
usercollection. ModSecurity 2.9 honours only the firstinitcolorsetuidfor a collection in each request, so if a vendor rule set loads first and opensuserunder its own key, rule 41000 is skipped and the counters are stored under that key instead. This lists any other rule that opens it:
grep -RniE 'initcol:user|setuid:' /etc/apache2 /etc/httpd /etc/modsecurity /usr/share/modsecurity-crs 2>/dev/null | grep -v 'id:41000'If that prints anything, change setuid: to setrsc: in rule 41000 and user to resource throughout the rest of your copy of the rules (setvar:resource., expirevar:resource., RESOURCE: and %{resource.), and run the same check for initcol:resource|setrsc:.
Symptom: legitimate shoppers get 429 while browsing filters. Check the log for how many requests one visitor produced. Filter plugins that update the page with several background requests per click, and many customers sharing one address (an office, a university, or a mobile network), both push a single address past a tight limit. Raise the limit in rule 41036 based on what you see.
Symptom: a search engine receives 429 on filter URLs. Google treats 429 as a server error. Repeated 429s make Googlebot slow its crawling of the whole site, not just the filter URLs, and URLs that keep returning server errors can eventually drop out of the index. The main fix is to keep crawlers away from filter and add-to-cart URLs in robots.txt:
User-agent: *Disallow: /*?*add-to-cart=Disallow: /*?*filter_Disallow: /*?*min_price=Disallow: /*?*max_price=If Googlebot still reaches the limit after that, raise the limit in rule 41036. Google also publishes the IP ranges its crawlers use; if you choose to exempt those addresses from this limit, keep your copy of the list up to date.
Symptom: a mobile app or another service stops connecting after you add the XML-RPC rules. With Option A, that service needs XML-RPC: switch to Option B, or on Plesk block xmlrpc.php per site with WP Toolkit instead. With Option B, check the log for rules 41022 and 41027 and the method or count they reported.
Symptom: you need to turn one rule off quickly. Comment it out in your copy of the rules: put # at the start of every line of that rule, including the indented chained lines, then save (Plesk), save and deploy (WHM) or reload Apache. Run the test commands again to confirm the rule has stopped matching.
Symptom: you locked yourself out while testing. The counter clears when its window ends. Wait for the window in the relevant rule to pass, then try again.
Need a hand?
Section titled “Need a hand?”If your server is on a managed Noiz plan, open a ticket and Noiz can advise on protection for your sites and tune it against your own traffic. For anything else, contact Noiz support.
[noiz] | disrupt the signal