Skip to content

AI Code Tutorial · No. 03

Your app is not WordPress. Stop answering scanners as if it were.

A wave of PHP and WordPress vulnerabilities has bots probing every domain for wp-login.php, .env and /feed/. If your app is not written in PHP, none of those requests should reach it. This tutorial gives you one Cloudflare rule that refuses them at the edge, and a brief for a coding agent to fit it to your routes so it never blocks a real visitor.

13 min read Beginner
SecurityCloudflareWAFBots
See the rule

Why now.

The last five weeks have been rough for PHP. On 22 September, WordPress shipped a fix for CVE-2026-87902, an unauthenticated flaw in WordPress core itself; Patchstack saw the first probes the same morning, and ten times the volume the next day once a public scanning template appeared. From 7 October attackers went after the Bricksforge plugin, which let anyone upload and run PHP code.

Then on 21 October came PHP2Shell: two remote code execution flaws in the PHP runtime, CVE-2026-109618 and CVE-2026-109619, affecting every PHP installation that serves web requests. Amazon’s honeypots had recorded an attempt four days before the disclosure, according to Help Net Security. In the first 24 hours after it, Imperva counted 482,342 attack requests against 21,998 sites in 103 countries, and called it “spray-and-pray” scanning. Two days later PHP published a related advisory that affects even the freshly patched releases.

Spray-and-pray means the bots do not check what you run before they ask. A Python, Node, Go, Ruby or Java app behind a public domain gets the same requests as a WordPress blog. Each one costs a little: a worker busy rendering a 404, a line in your logs, noise in your error tracker that buries the real problems. None of it can succeed, and all of it reaches your origin.

If your domain is behind Cloudflare, you can stop it there. One custom rule, available on the Free plan, recognises the paths that only scanners ask for and blocks them before they reach your app.


The idea

Answer at the edge, not at the origin.

Every request to a proxied domain passes through Cloudflare first. A custom rule there can look at the path and refuse the request before it travels any further. Your app never sees it, so it never spends anything on it.

Fig. 1 — The same scanner request, without and with the rule
A scanner request reaching the origin, then stopped at Cloudflare Top lane, without the rule: a scanner asks for /wp-login.php, Cloudflare passes it through, and the origin app renders a 404, writes a log line and keeps a worker busy. Bottom lane, with the rule: the same request is refused at Cloudflare with a 403 and never reaches the origin, while a real visitor's request for /pricing passes through as before. WITHOUT THE RULE Scanner GET /wp-login.php Cloudflare passes it through Your app renders a 404 writes a log line holds a worker WITH THE RULE Scanner GET /wp-login.php Visitor GET /pricing Cloudflare custom rule checks the path 403 at the edge Your app only real visitors A scanner request reaching the origin, then stopped at Cloudflare WITHOUT THE RULE Scanner · GET /wp-login.php Cloudflare passes it through Your app renders a 404, writes a log line, holds a worker WITH THE RULE Scanner · GET /wp-login.php Visitor · GET /pricing Cloudflare custom rule checks the path Scanner: 403 at the edge, stops here Your app: only real visitors

Paths, not payloads.

Cloudflare’s managed rules inspect requests for exploit code. This rule asks a simpler question: could this path exist on my app? A Flask or Next.js app has no xmlrpc.php and no .git folder on the web. Anything asking for one is a scanner.

Paths outlast addresses.

Scanners rotate through proxies; in one Bricksforge campaign 44 addresses sent 56 requests within seconds. Blocking IPs is a losing game. The paths they want, from .env to phpmyadmin, have barely changed in ten years.


If your site runs PHP

This rule is not for you. Yet.

The rule works because a non-PHP app has nothing at /wp-admin/ or /index.php. On WordPress those are your admin and your home page. Take out every line that matches your own site and you are left with a rule that protects very little.

For now, do what the Cloudflare and Datadog write-ups say. Update PHP itself to 8.5.12, 8.4.27, 8.3.36 or 8.2.35 or later, or your vendor’s backport: PHP2Shell sits in the runtime, so updating WordPress or a plugin does not fix it. Keep Cloudflare’s managed rules on, in block mode on paid plans. Update or remove plugins you no longer need, including ones closed on WordPress.org without a fix.

Then ask the bigger question. Every one of these flaws reached sites through a PHP file the web server was willing to run. A brochure site or blog may not need a server-side runtime at all: a static site generator or a headless CMS serves plain files that have nothing to execute. A custom app can be moved to Python, Node or Go, and a coding agent makes that rewrite far smaller than it was two years ago. After the autumn PHP has had, this is a good moment to upgrade your stack, and the rule on this page becomes yours too.


The rule

One expression, four parts.

This is a starting point, built from paths that show up over and over in the 404 logs of real non-PHP apps. Do not paste it unchanged: the next section shows what it can block on your site, and the agent’s job is to fit it to your routes.

1 · Which hosts

Only your own hostnames. On a zone with several apps, list the ones the rule protects.

2 · Never block

Exceptions that win over everything below. /.well-known/ carries certificate renewals, security.txt and app links. Add your own: a download route that serves zip files, for example.

3 · File extensions

Server-side scripts, config files, backups, archives and source maps. Cloudflare lowercases this field for you.

4 · Path fragments

Hidden folders, WordPress paths, admin tools, credential files, framework debug pages, mail provider keys and shell-injection tricks.

Cloudflare expression · about 3,250 of the 4,096 characters allowed

(http.host in {"example.com" "www.example.com"})
and not starts_with(lower(http.request.uri.path), "/.well-known/")
and (
  (http.request.uri.path.extension in {"php" "php7" "phtml" "asp" "aspx" "jsp" "cgi" "pl" "cfm" "env" "ini" "conf" "sql" "bak" "old" "swp" "yml" "yaml" "zip" "tar" "gz" "tgz" "rar" "7z" "war" "dll" "exe" "sh" "map" "tfstate" "owa"})
  or starts_with(lower(http.request.uri.path), "/.")
  or (lower(http.request.uri.path) contains "wp-")
  or (lower(http.request.uri.path) contains "wordpress")
  or (lower(http.request.uri.path) contains "xmlrpc")
  or (lower(http.request.uri.path) contains "/.env")
  or (lower(http.request.uri.path) contains "/.git")
  or (lower(http.request.uri.path) contains "/.svn")
  or (lower(http.request.uri.path) contains "/.aws")
  or (lower(http.request.uri.path) contains "/.ssh")
  or (lower(http.request.uri.path) contains "/.vscode")
  or (lower(http.request.uri.path) contains "phpmyadmin")
  or (lower(http.request.uri.path) contains "phpunit")
  or (lower(http.request.uri.path) contains "adminer")
  or (lower(http.request.uri.path) contains "cgi-bin")
  or (lower(http.request.uri.path) contains "credentials")
  or (lower(http.request.uri.path) contains "password")
  or (lower(http.request.uri.path) contains "secret")
  or (lower(http.request.uri.path) contains "smtp")
  or (lower(http.request.uri.path) contains "sendgrid")
  or (lower(http.request.uri.path) contains "mailgun")
  or (lower(http.request.uri.path) contains "sparkpost")
  or (lower(http.request.uri.path) contains "application.properties")
  or (lower(http.request.uri.path) contains "authorized_keys")
  or (lower(http.request.uri.path) contains "id_rsa")
  or (lower(http.request.uri.path) contains "id_dsa")
  or (lower(http.request.uri.path) contains "id_ecdsa")
  or (lower(http.request.uri.path) contains "id_ed25519")
  or (lower(http.request.uri.path) contains "private-key")
  or (lower(http.request.uri.path) contains "private_key")
  or (lower(http.request.uri.path) contains "dump.")
  or (lower(http.request.uri.path) contains "backup")
  or (lower(http.request.uri.path) contains "server-status")
  or (lower(http.request.uri.path) contains "actuator")
  or (lower(http.request.uri.path) contains "telescope")
  or (lower(http.request.uri.path) contains "_ignition")
  or (lower(http.request.uri.path) contains "autodiscover")
  or (lower(http.request.uri.path) contains "$(")
  or (lower(http.request.uri.path) contains "/php/")
  or ends_with(lower(http.request.uri.path), "/php")
  or (lower(http.request.uri.path) contains "graphql")
  or (lower(http.request.uri.path) contains "proc/self")
  or (lower(http.request.uri.path) contains "/@fs/")
  or (lower(http.request.uri.path) contains "__vite")
  or (lower(http.request.uri.path) contains "userfiles")
  or (lower(http.request.uri.path) contains "debug-trigger")
  or (lower(http.request.uri.path) contains "/owa/")
  or (lower(http.request.uri.path) contains "/evo/login")
  or (lower(http.request.uri.path) contains "/pma/")
  or ends_with(lower(http.request.uri.path), "/env")
  or ends_with(lower(http.request.uri.path), "/config")
  or ends_with(lower(http.request.uri.path), "/feed")
  or ends_with(lower(http.request.uri.path), "/feed/")
)
Why lower() everywhere. Paths are case-sensitive, and scanners try /WP-LOGIN.PHP too. Lowercasing the path once per test catches every spelling.
No regular expressions. contains, starts_with and ends_with work on every plan. Regex matching needs Business or Enterprise.
Room to grow. An expression can be 4,096 characters. Each extra line costs about 60, so there is room for a dozen more patterns before you need a second rule.

Check before you deploy

What it can block by mistake.

A contains test does not know what a word means. We ran ordinary paths through the rule above; every one of these is blocked. If one of them is a real route on your site, change the rule before you deploy it.

PatternA legitimate path it blocksWhat to do
contains “password” /reset-password, /forgot-password Remove it, or add the route to the exceptions
contains “wordpress”, “wp-” /blog/wordpress-vs-ghost, /swp-tips Exclude your blog path, or narrow to /wp-
contains “graphql” /api/graphql Remove it if you serve GraphQL
contains “backup”, “secret” /blog/my-backup-strategy, /account/secret-santa Exclude content paths such as /blog/
ends with /feed, /feed/ /feed/, /comments/feed/ Remove both if your RSS feed lives there
ends with /config /settings/config Remove it if it is a real page
extension yml, yaml /docs/openapi.yaml Add the file to the exceptions
extension zip, gz, tar /downloads/report.zip Exclude your download route
extension map /static/app.js.map Keep if you hide source maps on purpose
contains “actuator” /actuator/health Remove it on Spring Boot apps

What passes untouched.

/feedback, /rss.xml, /sitemap.xml, /robots.txt, /favicon.ico, .css and .js files, /api/v1/…, and anything under /.well-known/. Most apps need only a handful of changes. The point of the agent is that it finds them all, not most of them.


The workflow

Six phases, each with a gate.

The agent reads your code and your logs, builds the expression and proves it offline. You paste it into Cloudflare yourself. The agent never needs a Cloudflare credential for this job.

Fig. 2 — Fitting the rule to your app, phase by phase

  1. PHASE 0

    Inspect

    Read-only. Every route the app serves, every static file, the sitemap, and the paths that 404 in your logs.

    Gate: You see the route list

  2. PHASE 1

    Fit

    Start from the template. Remove patterns that hit real routes, add exceptions, add probes from your logs.

    Gate: Every change explained

  3. PHASE 2

    Test offline

    Mirror the rule in a script. Every real path must pass; every logged probe must be blocked.

    Gate: Zero real paths blocked

  4. PHASE 3

    Deploy

    You paste the expression into a new custom rule and choose the action.

    Gate: Rule live

  5. PHASE 4

    Verify

    Probe paths return 403 from Cloudflare; real pages return 200; origin 404s drop.

    Gate: All tests pass

  6. PHASE 5

    Maintain

    New probe paths from the logs, new routes from the code. Recheck both.

    Gate: A monthly habit


Phase by phase

What the agent actually does.

You do not need to type any of this. It is here so you can follow along and judge the agent’s report.

Phase 0 · read-only

Know every path you serve.

  • Routes from the code. Every route the framework registers, including dynamic ones such as /blog/<slug>. For those, the real slugs from the database or content folder, because a slug is where words like “backup” hide.
  • Files on disk. Everything under the static and public folders, with its extension.
  • The sitemap and the outside world. Every URL in the sitemap, plus webhooks, OAuth callbacks and API paths that other services call. Those never show up in a browser.
  • What scanners ask for. The paths that returned 404 over the last weeks, from your app or platform logs, grouped and counted.
  • Which hostnames. Every hostname on the zone that points at this app, and whether its DNS record is proxied. A rule cannot see traffic to a grey-cloud record.
# the most requested paths that 404,
# from a log export with method, path, status
grep ' 404 ' app.log \
  | awk '{print $2}' \
  | sort | uniq -c | sort -rn | head -50

# typical result on a non-PHP app
  412 /wp-login.php
  298 /.env
  171 /xmlrpc.php
  133 /wp-admin/setup-config.php
   97 /.git/config
   64 /feed/
   ...

Gate: the agent shows you the full list of real paths and the top probe paths, and confirms the app serves no PHP.

Phase 1

Fit the template to the app.

  • Hosts first. Replace example.com with the proxied hostnames from phase 0.
  • Remove what collides, or except it. For each real path the template blocks, choose the smaller change: an exception with not starts_with for a whole section such as /blog/, or removing a pattern you do not need. Every change gets a one-line reason.
  • Add what your logs show. A probe path that recurs in the 404s and is not caught yet becomes a new line. Prefer a distinctive fragment, /evo/login, over a common word.
  • Count the characters. The expression must stay under 4,096. Past that, split it into two rules.

Gate: a diff against the template, with a reason for every change, and the character count.

Phase 2 · the important one

Prove it offline.

Cloudflare’s Log action, which records matches without blocking, is Enterprise only. So the testing happens before you deploy. The agent writes a short script that mirrors the expression, reading the patterns straight out of the expression text so the two cannot drift apart, and runs two lists through it.

  • Every real path must pass. Routes, real slugs, static files, sitemap URLs, webhooks, /.well-known/.
  • Every logged probe should be blocked. The ones that slip through are candidates for phase 1.
  • Mirror the extension field exactly. Cloudflare takes the text after the last dot of the last segment, lowercased. A leading dot alone does not count: /.env has no extension, and is caught by the /. test instead.
# rule_test.py — run in a scratch folder
import re, sys
src = open('expr.txt').read()
EXT  = set(re.search(r'extension in \{([^}]*)\}', src)
           .group(1).replace('"', '').split())
P    = r'\(lower\(http.request.uri.path\), "([^"]+)"\)'
HAS  = re.findall(r'contains "([^"]+)"', src)
ENDS = re.findall(r'ends_with' + P, src)
STRT = re.findall(r'(?<!not )starts_with' + P, src)
SKIP = re.findall(r'not starts_with' + P, src)

def extension(path):
    seg = path.rsplit('/', 1)[-1].lower()
    s = seg[1:] if seg.startswith('.') else seg
    return s.rsplit('.', 1)[1] if '.' in s else ''

def blocked(path):
    p = path.lower()
    if any(p.startswith(s) for s in SKIP):
        return False
    return (extension(path) in EXT
            or any(p.startswith(s) for s in STRT)
            or any(h in p for h in HAS)
            or any(p.endswith(e) for e in ENDS))

# real.txt must print nothing
for line in open(sys.argv[1]):
    if blocked(line.strip()):
        print('BLOCKED', line.strip())

# python3 rule_test.py real.txt
# python3 rule_test.py probes.txt  (inverted check)

Gate: zero real paths blocked, and a short list of probes that still get through, with a decision for each.

Phase 3 · you

Deploy it yourself.

The agent hands you the final expression in a file. You paste it into a new custom rule, as the next section shows, and choose Block. If one pattern still worries you, start with Managed Challenge for a day: a person who hits it by mistake can still get through, while scanners cannot. Then switch to Block.

Phase 4

Verify from the outside.

# probes: 403 from Cloudflare
for p in /wp-login.php /.env /xmlrpc.php /feed/; do
  curl -s -o /dev/null -w "%{http_code} $p\n" \
    "https://example.com$p"
done

# real pages: 200, cache-busted
for p in / /pricing /sitemap.xml; do
  curl -s -o /dev/null -w "%{http_code} $p\n" \
    "https://example.com$p?v=$(date +%s)"
done

Then Security › Events shows each block with the rule’s name, and over the next days the 404s in your own logs should drop to a trickle.

Phase 5

Keep it fitted.

Two things change over time: the scanners and your app. Once a month, look at what still 404s on the origin and add the recurring ones. And whenever you add a route or publish a post, run the offline test again: a new page called /password-manager-guide would be blocked silently, and nobody would tell you.


Adding it in Cloudflare

Two minutes in the dashboard.

  1. 01 Open your zone and go to Security › Security rules.
  2. 02 Select Create rule › Custom rules.
  3. 03 Name it so you will recognise it in the events log, for example “Block scanner paths”.
  4. 04 Switch from the field builder to Edit expression and paste the final expression.
  5. 05 Under Then take action, choose Block, or Managed Challenge for a first day.
  6. 06 Select Deploy, then run the phase 4 checks.

Plan limits

Free: 5 custom rules. Pro: 20. Business: 100. This rule uses one, on any plan.

Order with other rules

If you already have rules that skip security for your own bots or monitors, keep them above this one.

Is your origin reachable directly?

Scanners that find your origin’s own address skip Cloudflare entirely. Accept traffic only from Cloudflare, with a tunnel, authenticated origin pulls or a secret header the edge adds.


The checklist

Every test the agent runs.

Twelve checks, before and after. The offline ones are the ones that protect your visitors.

WhenTestHowPass
Before The app serves no PHP Search the code and static folders for .php files and a PHP runtime None
Before Every hostname is proxied DNS records for each hostname in the rule Orange cloud
Before Route list is complete Framework routes, real slugs, static files, sitemap, inbound webhooks Reviewed by you
Fit Every change has a reason Diff against the template One line each
Fit Expression fits Character count Under 4,096
Offline No real path is blocked Mirror script over the full route list Zero blocked
Offline Logged probes are blocked Mirror script over the 404 paths Gaps explained
Offline Mirror matches the rule Patterns parsed from the expression file, not retyped Same source
After Probes stop at the edge curl on four probe paths 403 from Cloudflare
After Real pages still work curl on home, a key page and the sitemap, cache-busted 200
After Blocks are attributed Security events filtered by the rule name Matches appear
After Origin noise drops 404 count in app logs, a day before and a day after Clearly lower

Failure modes

What can go wrong, and the fix.

A blog post disappears.

A slug containing “wordpress”, “backup” or “password” returns 403 to readers and to search engines. Test real slugs, not just route patterns, and exclude content sections where needed.

A webhook stops arriving.

A payment or email provider calls a path you never open in a browser. If it matches, the provider sees 403s and eventually gives up. List inbound webhooks in phase 0.

Certificates stop renewing.

ACME challenges live under /.well-known/, and the /. test would catch them. The exception is the first line for that reason. Never remove it.

The rule sees nothing.

A grey-cloud DNS record bypasses Cloudflare, and so does a scanner that knows your origin’s address. Proxy every hostname and lock the origin to Cloudflare.

The expression is refused.

Over 4,096 characters, or a typo in a quote or bracket. Split it into two rules; the dashboard tells you where the syntax fails.

Your stack changes.

Adding a GraphQL endpoint, a Spring Boot service or a docs file in YAML behind the same hostname. Rerun the offline test whenever routes change.

The prompt

Hand this to your coding agent.

Fill in the parts in square brackets. The copy button adds the template expression at the end for you. The agent works read-only and hands you an expression to deploy yourself.

You are going to fit a Cloudflare custom rule to this app, so that vulnerability scanners asking for PHP, WordPress and secret-file paths are blocked at the edge, and no real visitor, crawler or webhook is ever blocked.

App: [framework and language], in this repository
Hostnames behind Cloudflare: [example.com, www.example.com]
Where the logs are: [platform log command, or a log export file]
Content with dynamic slugs: [e.g. blog posts in the database, docs in a content folder, or none]
Inbound webhooks and callbacks: [list the ones you know, the agent will look for more]

The template expression is pasted at the end of this message.

HARD RULES
- Read-only. Do not change the app, its config or any Cloudflare setting. You do not need a Cloudflare credential for this job.
- Never print secrets from environment files or logs. If a log line contains a token or an email address, leave it out of your report.
- Work in a scratch folder outside the repository. Do not commit anything.
- Stop and wait for me after Phase 0 and after Phase 2, and whenever a check fails.

PHASE 0 - INSPECT
1. Confirm the app serves no PHP: no .php files, no PHP runtime in the build. If it does, stop and tell me; this rule is not for PHP sites.
2. List every path the app serves: all registered routes, every file under the static and public folders, every URL in the sitemap, and every inbound webhook or OAuth callback path. For dynamic routes, fetch the real slugs from the content source, read-only.
3. From the logs, list the paths that returned 404 over the longest period available, grouped and counted, top 100.
4. Report which hostnames serve the app and whether each is proxied through Cloudflare, if you can tell.
Then show me the route list and the top probes, and wait.

PHASE 1 - FIT
5. Copy the template into scratch/expr.txt. Replace the hostnames.
6. For every real path the template would block, make the smallest change: an exception (and not starts_with(...)) for a whole section, or removing a pattern this app does not need. Never remove the /.well-known/ exception.
7. Add recurring probe paths from the logs that the template misses. Prefer distinctive fragments over common words.
8. Keep the expression under 4,096 characters. If it does not fit, split it into two rules.

PHASE 2 - TEST OFFLINE
9. Write scratch/rule_test.py that parses the patterns straight out of scratch/expr.txt (never retype them) and decides, for a path, whether the rule blocks it. Mirror Cloudflare exactly: lower() on the path; http.request.uri.path.extension is the lowercased text after the last dot of the last path segment, ignoring a single leading dot; not starts_with exceptions win.
10. Run every real path from Phase 0 through it. The result must be zero blocked. Show me any that are.
11. Run every logged 404 path through it. List the ones that are not blocked and say, for each, whether to add a pattern or leave it.
Then give me: the final expression, the character count, a diff against the template with a one-line reason per change, and both test results. Wait.

PHASE 3 - HAND OVER (I deploy it)
12. Write the dashboard steps: Security > Security rules > Create rule > Custom rules, Edit expression, paste, action Block (or Managed Challenge for a first day if a pattern is uncertain), Deploy.

PHASE 4 - VERIFY (after I tell you it is live)
13. With curl, request four probe paths on each hostname: each must return 403 from Cloudflare.
14. Request the home page, two key pages and the sitemap with a cache-busting query string: each must return 200.
15. Compare the number of 404s in the app logs over a day before and a day after the rule.
16. Give me a short report: what the rule blocks, the exceptions and why, each check and its result, and a reminder to rerun scratch/rule_test.py whenever routes or slugs change.

TEMPLATE EXPRESSION
[paste the expression from the tutorial here]

Works with any agent that can read your code and run shell commands: Claude Code, Codex, Cursor and the like.


Questions

The short answers.

Does Cloudflare’s managed ruleset not already do this?

It does something different. Managed rules look inside requests for known exploit payloads, which is what protects a PHP site from PHP2Shell. This rule refuses paths that cannot exist on your app at all, whatever the payload. It catches the reconnaissance before any exploit is tried, and it is cheap to evaluate. Keep both on.

My site runs WordPress, Laravel or another PHP framework. Can I use it?

No. The rule blocks .php files and paths containing wp-, which is your own site. Strip those lines out and very little protection is left. Patch PHP and keep Cloudflare’s managed rules on instead. And consider whether this is a good moment to move off PHP for good; the section on PHP sites explains the options.

Will it hurt my search rankings?

Not if your real pages are allowed, which is what the offline test checks. Search engines crawl the URLs they find in your sitemap and links. They do not request wp-login.php on a site that has never had one. The test in phase 2 runs your sitemap through the rule and requires every URL to pass.

I publish an RSS feed at /feed. Now what?

Remove the two /feed lines. They are there because /feed/ is the WordPress feed address and scanners use it to fingerprint WordPress. If your own feed lives there, that line blocks your subscribers. The same goes for any other pattern that matches one of your routes.

Why not block the scanners’ IP addresses instead?

They change constantly. In one Bricksforge campaign, Patchstack counted 44 IP addresses sending 56 requests within seconds of each other. An IP list is out of date before you finish writing it. The paths they ask for are far more stable than the addresses they come from.

Does it work on the Free plan?

Yes. It uses one of the five custom rules the Free plan allows, and no regular expressions, which only Business and Enterprise plans have. The Log action is Enterprise only, which is why the testing happens offline, before you deploy.

Does this stop a determined attacker?

No. It removes noise: the automated sweeps that hit every domain on the internet. Someone targeting your app will go after the routes you do serve. For that you still need patched dependencies, authentication done right and an origin that only accepts traffic from Cloudflare.