WordPress Hardening Plugin for ModSecurity CRS: Block Attacks Without Touching Your PHP

So. You’ve got a WordPress site. You’ve got NGINX in front of it. You’ve even got the OWASP ModSecurity Core Rule Set (CRS) loaded because someone told you it was the responsible thing to do. Good job. Gold star. But here’s the uncomfortable truth: the default CRS is a generalist. It’s built to protect everything from a corporate intranet wiki to a government tax portal. WordPress has very specific, very well-known attack surfaces — and the stock CRS doesn’t cover them nearly as tightly as it could.

Enter the wordpress-hardening-plugin — an open-source ModSecurity CRS plugin that adds 25+ targeted hardening rules specifically for WordPress, without touching a single line of PHP, without installing yet another WordPress plugin, and without breaking your site in mysterious ways at 3 AM. Let’s dig in.

What Even Is a ModSecurity CRS Plugin?

Quick orientation for the newcomers. ModSecurity is a Web Application Firewall (WAF) engine that sits inside your web server — NGINX, Apache, Caddy — and inspects every HTTP request before it reaches your application. The OWASP Core Rule Set (CRS) is a curated collection of detection rules that ModSecurity loads: SQL injection patterns, XSS signatures, path traversal attempts, and so on.

Since CRS 4.0, the project introduced a plugin system: drop extra rule files into a specific directory and they load automatically, layered on top of the base CRS. No patching core files, no forking the ruleset, no maintenance headaches. The wordpress-hardening-plugin is exactly that kind of drop-in — it adds WordPress-specific rules without modifying anything the CRS project ships.

Think of it like this: the CRS is a really good bouncer who knows every general troublemaker in town. The wordpress-hardening-plugin hands that bouncer a laminated cheat-sheet of every WordPress-specific attack pattern in the book. Same bouncer, much sharper instincts.

Why Not Just Use a WordPress Security Plugin?

Fair question. Wordfence, Sucuri, iThemes Security — they all exist. Here’s why a WAF-layer approach is better for performance-conscious sites:

  • PHP never loads for blocked requests. When ModSecurity rejects a request at the NGINX layer, WordPress doesn’t boot, PHP-FPM doesn’t spin up, MySQL doesn’t get queried. A brute-force attack against your login page consumes almost zero server resources.
  • No attack surface inside WordPress. A PHP-based security plugin can itself be exploited if a zero-day hits WordPress core or another plugin. A rule in NGINX’s ModSecurity module has zero exposure to WordPress-level vulnerabilities.
  • One place to manage WAF rules. If you run multiple WordPress sites behind a shared NGINX instance, one rule file protects all of them.
  • No plugin update fatigue. One less WordPress plugin to keep updated, one less potential supply-chain attack vector.

What the Plugin Actually Blocks — All 25+ Rules Explained

Let’s go through every protection this plugin provides. No hand-waving, no vague “enhanced security” marketing-speak — actual explanations of what each rule does and why it matters.

1. Block xmlrpc.php (default: ON)

xmlrpc.php is a legacy remote-procedure-call interface that WordPress has shipped forever. It was designed for blogging clients that predate the REST API. Today it’s almost entirely used by attackers — it supports credential brute-forcing with a single HTTP request that tests thousands of username/password combinations (the “system.multicall” technique), it enables DDoS amplification via pingback, and it exposes file upload functionality. Unless you’re using a mobile app that specifically requires it, this endpoint has no business being publicly accessible. The plugin blocks it at Paranoia Level 1 and whitelists localhost and RFC 1918 ranges so your internal monitoring tools still work.

2. Block User Enumeration (default: ON)

WordPress leaks usernames in at least two ways: the ?author=1 redirect (which reveals the login name in the URL) and the REST API’s /wp/v2/users endpoint. Attackers harvest these usernames as the first step in a targeted credential attack. No username, no brute force. This rule blocks both vectors at PL1.

3. Block Login Attempts with Username “admin” (default: ON)

The single most common WordPress brute-force username is literally “admin”. If your WordPress installation doesn’t have a user named “admin” — and it shouldn’t, because that’s day-one security hygiene — this rule blocks any login attempt using that username before the request even touches PHP. It’s a near-zero false-positive rule that deflects an enormous proportion of automated attacks.

4. Block wp-json REST API (default: OFF)

The WordPress REST API powers the Gutenberg editor, many themes, and a huge chunk of the plugin ecosystem. Blocking it wholesale would break most modern WordPress sites, which is why it’s off by default. But if you’re running a static-content site, a headless setup, or you’ve explicitly moved REST API consumers to an internal network, enabling this rule shuts down a significant data-exposure surface (unauthenticated post listing, taxonomy traversal, etc.).

5. Block wp-cron.php (default: OFF)

WordPress uses wp-cron.php for scheduled tasks: publishing scheduled posts, clearing caches, sending digest emails. By default it’s triggered on every page load from any visitor, which is inefficient and occasionally exploitable. The plugin can block public access; if you’ve already set up a proper system cron job (which you should), the endpoint is useless to the public anyway. The whitelist ensures your cron daemon, which runs from localhost, keeps working.

6. Block Directory Listing and Direct PHP Access in wp-content/wp-includes

These two rules block directory listing (returning a file index when no index.html exists) and direct HTTP access to PHP files inside /wp-content/ and /wp-includes/. Legitimate WordPress requests go through index.php; direct PHP execution in these directories is either a mistake or an attacker trying to run a webshell or exploit a known-vulnerable plugin file directly.

7. Block Alternative Interpreters (.pl, .lua, .py, .sh) — PL2

If your server is misconfigured to execute Perl, Lua, Python, or shell scripts via the web server, an attacker who manages to upload a file can turn a file-upload vulnerability into full remote code execution. These PL2 rules block HTTP requests to files with those extensions in the WordPress directory tree.

8. Block Dangerous Uploads

The /wp-content/uploads/ directory is world-writable by design — WordPress puts user-uploaded images there. It’s also a favourite landing zone for webshells uploaded through vulnerable plugins. This rule blocks requests to files in the uploads directory that have executable or dangerous extensions.

9. Block Sensitive File Access (.db, .orig, .sql, .log, .git)

Developers leave things lying around. Database exports (.sql), backup files (.orig), SQLite databases (.db), log files (.log), and exposed git repositories (.git/HEAD) are routinely discovered on production WordPress sites by automated scanners. This rule blocks access to all of them.

10. Block the Exact /wp-json Path

Even when the full REST API blocking is off, the bare /wp-json path (with no trailing slash or endpoint) returns a full API manifest — a directory of every registered endpoint on your site, with namespaces and route details. It’s reconnaissance. This rule blocks that exact path while leaving the full API functional.

11. Block wp-admin Theme/Plugin Editor (default: ON)

WordPress ships with a built-in code editor at /wp-admin/theme-editor.php and /wp-admin/plugin-editor.php. If an attacker compromises an admin account, this editor is a one-click path to dropping a PHP webshell into your theme files. The plugin blocks these endpoints at the WAF layer — even a fully compromised admin session can’t use them.

12. Block Backup Directory and Archive Access (default: ON)

Backup plugins (UpdraftPlus, BackWPup, All-in-One WP Migration) sometimes store archives in directories accessible via the web. This rule blocks requests to common backup directory paths and archive file extensions (.zip, .tar.gz, .tar.bz2) in the WordPress tree.

13. Block Compressed Database Exports (default: ON)

Specifically targets .sql.gz, .sql.bz2, and .sql.zip files — compressed database dumps that shouldn’t exist in a web-accessible path but frequently do after a migration or a hasty manual backup. These contain your entire database including password hashes, email addresses, and all site content.

14. Block Directory Traversal in Uploads (default: ON)

Blocks path traversal attempts (../, URL-encoded variants) in requests to /wp-content/uploads/. Prevents an attacker from escaping the uploads directory to read arbitrary files on the server through a vulnerable file-serving code path.

15. Block Null Byte Injection — PL2 (default: ON)

Null bytes (%00) in URIs and request parameters are a classic technique to truncate strings in C-based code (old PHP versions, file system calls), bypass extension checks (shell.php%00.jpg), and confuse parsing logic. This PL2 rule blocks requests containing null bytes anywhere in the URI or parameters.

16. Block Known Security Scanners — PL2 (default: OFF)

Blocks requests from well-known vulnerability scanner user-agent strings: nikto, sqlmap, wpscan, and others. Off by default because legitimate penetration testers use these tools, and blocking on user-agent alone is easily bypassed anyway. Enable it as a quick noise-reducer on heavily-scanned public sites.

17. Block XDebug and phpinfo Probes (default: ON)

Blocks requests containing XDebug trigger headers (XDEBUG_SESSION_START, XDEBUG_SESSION) and phpinfo probe parameters. These are development tools that should never be accessible on a production server; finding one live is a significant information disclosure.

18. Block Code Injection in wp-login.php Parameters (default: ON)

Matches common code injection patterns (<script>, eval(, base64_decode, onload=, onerror=) in the log and pwd POST parameters of wp-login.php. These are the username and password fields — no legitimate login attempt has JavaScript or PHP code in them.

19. Block Dangerous wp-admin Endpoints (default: OFF)

Blocks access to /wp-admin/install.php, /wp-admin/setup-config.php, /wp-admin/upgrade.php, and /wp-admin/wp-activate.php. These are WordPress installation/upgrade endpoints that should be unreachable on a live production site. Off by default because enabling it during an actual WordPress upgrade would be annoying.

The Three Advanced Features: Rate Limiting, GeoIP, and IP Reputation

These three features were added more recently and represent a significant step up in sophistication. Each one addresses a class of attack that rule-matching alone can’t fully cover.

IP-Based Rate Limiting for wp-login.php

Brute-force attacks against wp-login.php are one of the most common WordPress attack vectors. A slow, distributed brute force — one attempt every few seconds from a rotating botnet — can evade detection by signature-based rules indefinitely. Rate limiting closes that gap.

The plugin tracks POST requests to /wp-login.php per source IP using ModSecurity’s persistent storage (ip.* collection). After the threshold is reached, every subsequent request from that IP is blocked until the window expires. Default settings: 5 attempts per 60-second window. Both values are configurable.

Localhost and RFC 1918 ranges are always whitelisted — your CI pipelines, monitoring agents, and load balancer health checks won’t trip it. You can add additional CIDRs (a datacenter range, a VPN subnet) via a single config line.

# Tighten to 3 attempts per 30 seconds
SecAction "id:9522049,phase:1,nolog,pass,t:none,setvar:'tx.wphard.ratelimit_login_attempts=3'"
SecAction "id:9522050,phase:1,nolog,pass,t:none,setvar:'tx.wphard.ratelimit_login_window=30'"

GeoIP-Based Access Control for wp-login.php

If your WordPress admin team is all in the Netherlands and Germany, why should wp-login.php be reachable from Russia, Brazil, or Nigeria? GeoIP access control lets you maintain an allowlist of country codes — only clients from those countries can even reach the login page. Everyone else gets a block before WordPress loads.

Crucially, no GeoIP database is required on the WAF server. The plugin reads a standard header that your upstream proxy already sets: Cloudflare sets CF-IPCountry, nginx with ngx_http_geoip2_module (available in our optimized NGINX builds) can set X-GeoIP-Country, HAProxy can set it too. The WAF just reads the header and checks the value against a plain text file of allowed two-letter ISO 3166-1 country codes.

# plugins/wordpress-hardening-login-countries.data
NL
DE
GB
US

Requests without a country header are allowed through (fail-open) — so if your proxy misconfigures the header, you lock yourself out of nothing. The feature is disabled by default; enable it with one SecAction line once you’ve verified your proxy is sending the header correctly.

IP Reputation Blocklist

The most aggressive feature: block all requests from known-bad IPs, not just login attempts. The blocklist is a plain text file that supports individual IPs and CIDR ranges. ModSecurity’s @ipMatchFromFile operator loads it at startup and evaluates it against the client IP on every request.

You populate the file from threat intelligence feeds. The README recommends several well-known free sources:

  • Spamhaus DROP — the “Don’t Route Or Peer” list of hijacked and maliciously-operated netblocks
  • Emerging Threats compromised host blocklists — IPs actively observed attacking honeypots
  • Firehol Level 1 — an aggregated feed of the most aggressive public blocklists

Client IP is sourced from X-Forwarded-For when present (for proxied traffic) and falls back to REMOTE_ADDR. Loopback and RFC 1918 addresses are always whitelisted — internal traffic can never match the blocklist. The feature is disabled by default; enable it once your blocklist file contains real entries.

# plugins/wordpress-hardening-ip-reputation.data
198.51.100.0/24
203.0.113.5
2001:db8::/32

IP Whitelisting — So You Don’t Block Yourself

The endpoints that the plugin optionally blocks — xmlrpc.php, /wp-json, wp-cron.php — frequently need to be accessible from internal systems: monitoring agents, backup services, load balancer health checks, internal API consumers. The plugin handles this with per-endpoint IP whitelists that default to allowing all localhost and RFC 1918 ranges.

You can extend each whitelist independently. Adding a datacenter CIDR to the xmlrpc whitelist doesn’t affect the REST API or wp-cron whitelist. The configuration is three SecAction lines in the config file.

SecAction "id:9522041,phase:1,nolog,pass,t:none,setvar:'tx.wphard.whitelist_xmlrpc_ips=127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 203.0.113.0/24'"

How to Install

The plugin follows the standard CRS plugin installation process. You need CRS 4.0 or newer and a ModSecurity-compatible WAF (ModSecurity v2/v3, Coraza). For our optimized NGINX builds with the http-modsecurity module already included, the setup is straightforward.

# 1. Navigate to your CRS plugins directory
cd /etc/nginx/modsecurity.d/owasp-crs/plugins/

# 2. Clone the plugin
git clone https://github.com/eilandert/wordpress-hardening-plugin .

# 3. The plugin files are already in the right place (plugins/ subdirectory)
# CRS auto-loads files matching plugins/*-before.conf and plugins/*-config.conf

# 4. Reload NGINX
nginx -t && systemctl reload nginx

That’s it. All features with sensible defaults are active immediately. Review plugins/wordpress-hardening-config.conf to tune or disable individual rules for your specific setup.

It’s also strongly recommended to install the companion wordpress-rule-exclusions-plugin alongside it — that plugin suppresses CRS false positives specific to WordPress (things like Gutenberg’s complex POST bodies), so you’re adding protection without adding noise.

The Design Philosophy

A few things are worth understanding about how this plugin is designed, because they affect how you should think about deploying it.

No PHP, no MySQL, no WordPress overhead. Every rule fires at the NGINX/ModSecurity layer. Blocked requests never reach PHP-FPM. On a site under heavy attack, this is the difference between staying up and falling over.

Gate variables for every blocking rule. Each group of blocking rules is wrapped in TX:wphard.* variables checked at phase 1. Disabling a feature is one commented-out SecAction line. There’s no need to delete rules or comment out SecRule blocks — the variable gate cleanly disables the entire feature.

Paranoia level aware. Rules are tagged with their paranoia level (PL1 or PL2). If you run a strict PL2 setup with the CRS, PL2 rules like null byte injection and scanner blocking engage automatically. If you’re on PL1 (the recommended default for most sites), you get the PL1 rules without PL2 noise.

Actively tested with go-ftw. The repository includes a full regression test suite for every rule, run via the OWASP CRS plugin test action against a real nginx+ModSecurity container on every commit. Rules don’t ship unless tests pass.

Related Reading

If you’re building a hardened NGINX + WordPress stack, these posts are worth reading alongside this one:

FAQ

Does this replace a WordPress security plugin like Wordfence?

It replaces a lot of what those plugins do at the network perimeter level — request blocking, brute force protection, IP blocking. What it doesn’t do: file integrity monitoring, post-compromise forensics, email alerting inside WordPress, or application-level security features. For a high-traffic site, using this plugin instead of a PHP-based WAF plugin is a significant performance and resilience improvement. For small blogs, the choice matters less.

Will it break my site?

The defaults are deliberately conservative. Rules that could cause false positives on common WordPress setups (REST API blocking, wp-cron blocking, dangerous admin endpoint blocking) are off by default or only block things no legitimate traffic ever does. Start with defaults, monitor your ModSecurity logs for a few days, then tighten gradually. The gate variable system makes it trivial to disable any specific rule that causes a problem.

What if I’m running Apache instead of NGINX?

ModSecurity runs on Apache too. The plugin rules are ModSecurity/CRS standard — they work identically on Apache mod_security2 and on any Coraza-based WAF. The installation path is different (Apache has its own CRS directory structure) but the plugin files themselves are server-agnostic.

My wp-login.php rate limiter is blocking my own IPs. How do I whitelist?

Add your IP ranges to tx.wphard.ratelimit_login_whitelist_ips in the config file. Localhost and all RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) are whitelisted by default. For a VPN or office IP, add a line like:

SecAction "id:9522051,phase:1,nolog,pass,t:none,setvar:'tx.wphard.ratelimit_login_whitelist_ips=127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 203.0.113.0/24'"

How do I enable GeoIP blocking if I’m not using Cloudflare?

You need a proxy in front of NGINX that sets either CF-IPCountry or X-GeoIP-Country headers. With our NGINX builds that include ngx_http_geoip2_module, you can set the header in NGINX itself before the request reaches ModSecurity: proxy_set_header X-GeoIP-Country $geoip2_data_country_code;. HAProxy, Traefik, and most CDNs offer equivalent functionality.

Is the IP reputation blocklist updated automatically?

Not automatically — the plugin ships the mechanism, you supply the data. Set up a cron job that downloads a threat feed (Spamhaus DROP, Firehol Level 1, etc.) into the data file and reloads NGINX. ModSecurity reloads the file on nginx -s reload without dropping connections.

Can I contribute or report false positives?

Yes — the project is open source at github.com/eilandert/wordpress-hardening-plugin. Open an issue with your CRS version, ModSecurity version, audit log excerpt, and what caused the false positive. Pull requests for new rules, test cases, or documentation improvements are welcome.

What’s on the roadmap?

Rate limiting for other endpoints beyond wp-login.php (wp-admin, xmlrpc, REST API) is on the list. The plugin follows the CRS plugin specification strictly, so any new features go through the same test harness before release.

The wordpress-hardening-plugin is MIT/Apache-2 licensed and maintained at github.com/eilandert/wordpress-hardening-plugin. CRS 4.0+ required. Works with ModSecurity v2, v3, and Coraza.