Hardening Cloudflare for WordPress: Four Layers, Two Traps, and the Tests That Prove It
Four layers, four dimensions of the same attack. Each one catches what the others can't.
Part 1 covered the PHP side: the dashboard said 8.3, the site was running 8.1. This part covers the edge — the layer that buys time while the engine gets patched, and the one you configure yourself.
The four layers, and what each one is for
The php2shell attack has four dimensions, and no single Cloudflare feature covers them all. The stack that does:
Layer 1 — Cloudflare Managed Ruleset (signatures)
The ruleset containing the php2shell-specific rules. Critical lesson from our build: the entitlement isn't the protection. On paid plans the ruleset must be deployed — and Cloudflare's own dashboard will tell you when it isn't: a banner reading "Your configuration doesn't protect [your domain] against php2shell!" with a one-click deploy button. We found an undeployed ruleset on a paid zone this way. Check yours: Security → Security rules → Managed rules → the ruleset should read Execute · Active.

the orange "doesn't protect against php2shell" banner

Managed Ruleset + OWASP both Execute/Active
Layer 2 — OWASP Core Ruleset (anomaly scoring)
A second managed layer that scores requests: individual rules contribute anomaly points (method-not-allowed +5, security-scanner user-agents +5, …), and requests crossing the threshold get blocked. The WordPress-safe configuration: Paranoia Level PL1 (higher levels false-positive on legitimate plugin traffic), threshold Medium (40+), action Block. Found under Security → Settings → Web application exploits → OWASP Core — on paid plans it's present but toggled off by default.
Layer 3 — Path challenge rule (the login surface)
The single highest-value custom rule for any WordPress site: challenge everything that reaches your login and admin area.
Layer 4 — Transport challenge rule (the exploit's shape)
php2shell's entry vector is a multipart POST. Gating that transport shape closes the door the exploit prefers — more on the crawl-first discipline below.
Layer 3 in full: the wp-admin challenge rule
Built with the visual builder — three rows:
| Join | Field | Operator | Value |
|---|---|---|---|
| — | URI Path | equals | /wp-login.php |
| Or | URI Path | starts with | /wp-admin |
| And Not | URI Path | equals | /wp-admin/admin-ajax.php |
Action: Managed Challenge. Compiles to:
(http.request.uri.path eq "/wp-login.php") or (starts_with(http.request.uri.path, "/wp-admin") and http.request.uri.path ne "/wp-admin/admin-ajax.php")
Three details that matter:
- "starts with," not "equals" on the wp-admin row — "equals" matches only the literal directory path and misses every page inside it (
edit.php,users.php… the actual targets). - The admin-ajax exemption is mandatory on most WordPress sites: forms from WPForms, MailPoet, and dozens of plugins POST to
/wp-admin/admin-ajax.phpfrom public pages. Challenge it and your signup forms silently break for visitors. - xmlrpc.php is a separate decision: if you run Jetpack, leave it alone — Jetpack communicates through it. If you don't, block or challenge it. (And if you don't use it at all, disable it at the WordPress level too — a one-line filter:
add_filter('xmlrpc_enabled', '__return_false');— so the application layer agrees with the edge.)

the three-row builder + clean expression preview
Layer 4 in full: the multipart transport rule
php2shell's exploit arrives as a multipart/form-data POST. The rule:
http.request.method eq "POST" and any(http.request.headers["content-type"][*] contains "multipart/form-data") and not starts_with(http.request.uri.path, "/wp-admin") and not starts_with(http.request.uri.path, "/wp-json")
Action: Managed Challenge. Three things to know before deploying:
- There is no
http.request.content_typefield — multipart detection goes through the headers map, as above. - Crawl before you gate. Check your own site for file-upload forms first — fetch your pages and grep for
type="file"andmultipart/form-data. Forms with upload fields POST multipart and would get challenged. (Our crawl of 40 pages found zero — most form plugins post urlencoded unless a form has a file-upload field.) - The Log action is Enterprise-only. The classic advice — "deploy in Log mode first, observe, then enforce" — isn't available below Enterprise. Your observation baseline instead: your origin access logs. If they show no legitimate multipart POSTs to frontend paths, you can deploy straight to challenge.
Trap 1: the rule that validated, deployed, showed Active — and matched nothing
This is the one we'd warn every Cloudflare admin about, because it ate both of our zones independently.
The rule editor has two modes: a visual builder (Field / Operator / Value rows) and a raw expression editor. If you paste a full expression into the builder's Value box, Cloudflare accepts it, validates it, deploys it — and creates a rule whose condition is the expression text as a literal string:
# What you pasted into the Value box: (http.request.uri.path eq "/wp-login.php") or (http.request.uri.path starts_with "/wp-admin" ...) # What the rule actually became: http.request.uri.path eq "(http.request.uri.path eq \"/wp-login.php\") or (...)" # ↑ the whole expression, # nested as a literal URL string
The Expression Preview can still look plausible. The rule shows Active. The matched-traffic stat reads 0.0% — which is the only visible symptom, and easy to misread as "quiet site." Our first deployment sat live and inert until an external test exposed it.
\") and an outer eq " wrapping everything; (3) the matched-traffic preview should show a non-zero percentage for a rule targeting real endpoints — 0.0% on a wp-admin rule means it matches nothing.
Events empty while the broken rule was deployed
Trap 2: the 500-error paste
Pasting .htaccess content (or any config file) from a notes app, chat, or an AI assistant can carry markdown formatting with it — prose, code-fence lines, bold markers. None of that is valid directive syntax: the site returns 500 Internal Server Error on every PHP request within seconds, and the ErrorDocument handler fails too. The rollback "fails" for the same reason if you re-paste the same wrapped content.
The rule: paste config from plain-text files whose last line is a real directive — nothing else. (Full story in Part 1.)
How to verify a rule is actually enforcing — the bot-side battery
A dashboard showing "Active" is a claim, not evidence. The proof is a test from outside a browser context:
# Gated paths — expect 403 + a cf-mitigated: challenge header:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-login.php
curl -sI https://example.com/wp-login.php | grep -i cf-mitigated
# Form endpoints — expect pass-through (origin response, NOT a challenge):
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-admin/admin-ajax.php
# Content — expect 200:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/And for rate limits, volume matters: fire more requests than your threshold in the window and watch for the 429. One nuance we hit: if a challenge rule covers the same path, it intercepts first — every request comes back 403 and the rate limiter's block is masked. That's correct layering: the challenge stops bots at the door; the rate limit stands behind it for anything that solves the challenge.
Also verify from the human side: open a private window, hit the gated path — you should pass (invisibly or with a brief interstitial) and reach the real page. Bots fail; you don't.

the challenge interstitial
What Free doesn't do (and why that's a plan decision, not a dealbreaker)
| Capability | Free | Paid (Pro+) |
|---|---|---|
| Managed Ruleset deployment controls + php2shell gap banner | Protection banner only | Deployment controls + targeted guidance |
| OWASP Core Ruleset | Not available | Deployable (off by default) |
| Rate-limit block duration | 10 seconds | 10 minutes |
| Custom-rule Log action | Not available | Not below Enterprise — but longer event history |
| Bot Fight Mode path exemptions | None — challenges all automated traffic | Super BFM with per-category control |
Free still gets the php2shell managed-rule protection by default — the banner proves it's live. But the controls (deployment verification, OWASP, meaningful rate-limit blocks) start at paid tiers. Size the plan to the site's stakes.
The near-miss worth more than the rules: media uploads are public
The best operational lesson from this build had nothing to do with WAF rules. Mid-walkthrough, incident screenshots were uploaded to WordPress's media library — including analytics views showing the origin server's IP. WordPress media is public by URL the moment it's uploaded, embedded in a post or not. Anyone with the URL pattern fetches the file.
The decisions to make deliberately (a checklist, not our answers)
- Skip rules: any rule that skips managed rules for matching traffic — by IP, path, or anything — is a published bypass. Audit every one; disable what you can't justify.
- Bot settings: map every server-to-server integration your site has (syncs, feeds, API pushes) before touching bot-fight settings — each one is a traffic class that "block automated" will catch.
- AI crawlers: decide deliberately which AI crawlers may crawl you. Verified-bot status and Cloudflare's AI bot policies are the controls — verified crawlers bypass automated-traffic blocks; policies gate the rest.
- Leaked-credential checks: if your plan includes it, enable it — it blocks login attempts using passwords known to be breached. Zero impact on legitimate users.
- SSL/TLS mode: Full (strict), explicitly — not Automatic, not Flexible. Strict validates your origin's certificate on every connection.
Questions this article answers
Which Cloudflare rules protect against php2shell (CVE-2026-109618 / CVE-2026-109619)?
The Cloudflare Managed Ruleset contains the php2shell rules — verify it's deployed with action Execute/Active on paid plans; on Free it's on by default (the protection banner confirms it). OWASP Core adds anomaly-scoring coverage.
Why is my Cloudflare custom rule not working even though it shows Active?
The most common cause: a full expression pasted into the visual builder's Value box — the rule matches the expression as a literal string and fires on nothing. Check the compiled preview for escaped quotes and nesting, and verify enforcement with an external request, not the dashboard.
Should I block admin-ajax.php in Cloudflare?
No — exclude it from wp-admin challenge rules. WordPress form plugins (WPForms, MailPoet, others) POST to /wp-admin/admin-ajax.php from public pages; challenging it breaks frontend forms silently.
Should I block xmlrpc.php?
If you use Jetpack, no — it communicates through xmlrpc.php. If you don't use XML-RPC, block it at the edge and disable it in WordPress (add_filter('xmlrpc_enabled', '__return_false')).
Can I deploy a Cloudflare rule in Log mode on Free or Pro?
No — the Log action is Enterprise-only. Use your origin access logs as the observation baseline, or deploy to Managed Challenge if your baseline shows no legitimate matching traffic.
Are WordPress media library uploads private?
No. Files in /wp-content/uploads/ are publicly fetchable by URL whether or not they're embedded in a post. Never upload incident screenshots containing origin IPs or security details.
Sources
- [TO ADD: Cloudflare Managed Ruleset documentation — developers.cloudflare.com]
- [TO ADD: Cloudflare OWASP Core Ruleset documentation]
- [TO ADD: Cloudflare custom rules / expression language reference]
- [TO ADD: Cloudflare plans and Log action availability]
- [TO ADD: WordPress XML-RPC filter documentation — developer.wordpress.org]