# My firewall rule would block my own article about firewall rules

> A firewall rule clause that matches a word, not a shape, will block an article whose URL contains it. No test against current traffic finds that.

Source: https://visibleorigin.com/firewall-rule-blocks-own-article/
Published: 2026-09-25

---

**The short version.** I deployed a firewall rule that blocks requests probing for `phpinfo` files. It works. It also blocks any article I might publish whose URL contains that word — and this site publishes articles about exactly those probes.

Nothing in my traffic would have revealed it. **The article that breaks the rule does not exist yet**, so no measurement taken today can find it. It took someone reading the rule and asking what I write about.

The fix costs nothing and covers the same probes. What it does not do is remove the last edge, and that turned out to be the more interesting half.

## Why the rule is a string match at all

Roughly 1,700 requests to this site are scans looking for credentials and configuration files. A single custom rule at the CDN stops them before they reach the server. Writing it is where the constraint appears.

Cloudflare's rules language has a regular-expression operator, and on the plan this site runs on it is not available. The [custom rules documentation](https://developers.cloudflare.com/waf/custom-rules/) puts it in a table — regex support is No on Free and Pro, Yes on Business and Enterprise — and the same table caps the plan at five rules. So a rule has to be built from the operators that are available, and the workhorse is `contains`, which the [operator reference](https://developers.cloudflare.com/ruleset-engine/rules-language/operators/) lists alongside the wildcard operators that do remain available on every plan.

One detail that bites people: `contains` is case-sensitive. The way round it is the `lower()` function, which the [function reference](https://developers.cloudflare.com/ruleset-engine/rules-language/functions/) describes precisely — it *“converts a string field to lowercase”* and *“only uppercase ASCII bytes are converted”*. Every clause in my rule wraps the path in it. Without that, `/PHPINFO.PHP` walks straight through.

The five-rule cap changes the calculus too. With twenty rules you can afford one clause per concern and keep each of them narrow. With five you end up concatenating unrelated patterns into a single rule, which is how a clause about filenames ends up sitting next to a clause about a word, reviewed as one object and deployed as one object.

## Ten clauses that cannot misfire, and one that can

My rule has twelve clauses. Eleven of them match on structure: dotfiles, directories, exact filenames. No article is ever going to be published at a URL containing `/.git/` or ending in `service-account.json`.

One clause matches on a word:

| Clause | Matches | Could a real article match? |
| --- | --- | --- |
| `contains "/.env"` | 206 probe paths | No — structural |
| `contains "/.git/"` | 2 | No — structural |
| `ends_with "credentials.json"` | 14 | No — a filename |
| `contains "phpinfo"` | 34 | **Yes — it is a word** |

The difference is not how many probes each clause catches. It is whether the thing it matches can also appear in a sentence somebody wants to read.

This site writes about server configuration. An article called *what phpinfo hides* is an entirely plausible thing for me to publish, and WordPress would give it a URL containing that word. **My own firewall would return 403 to every crawler that tried to read it.**

![Two panels comparing firewall rule clauses: clauses matching structural shapes such as dotfiles and exact filenames cannot collide with articles, while a clause matching the word phpinfo catches both the probe and a plausible article URL](https://visibleorigin.com/wp-content/uploads/waf-shape-vs-word.webp)
The distinction is not severity or coverage. It is whether the thing being matched can also occur in a sentence somebody wants to read.

## Why no amount of measuring would have caught it

This is the part worth generalising, because it applies to far more than one rule.

I checked the rule before deploying it: forty-seven real paths from this site — every URL in the sitemap, the agent files, the ACME challenge path, the admin endpoints, the fonts and images. Zero matched. That check was correct and it was worthless against this problem, because **the URL that breaks the rule has not been created yet**.

Any validation built on current traffic shares the blind spot. You can replay a month of logs, diff every live route, run the rule against every row in the sitemap, and none of it sees an article you have not written. The failure is not in the data. It is in the shape of the question: *does this rule break anything?* is a different question from *could this rule break something I might legitimately do later?*

The second one is not answered by measurement. It is answered by reading the rule and knowing what the site is for.

## And the failure would be silent

The reason this matters more than it sounds: from the author's side, nothing looks wrong.

| Where you would look | What you would see |
| --- | --- |
| The post in the admin | Published, correct, fine |
| Preview in a logged-in browser | Renders perfectly |
| The sitemap | Lists the URL |
| IndexNow submission | Accepted, 200 |
| **What a crawler receives** | **403** |

Four green lights and one red one, and the red one is the only place nobody looks.

A 403 served by the edge also looks different from one served by your own server, which is worth knowing when you are debugging someone else's site. Cloudflare's [documentation on 403s](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-403/) says that a 403 *“without the Cloudflare branding”* is coming from the origin rather than from them. The branding is the tell.

And search engines act on it. Google's [status code documentation](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes) states that Google *“doesn't use the content from URLs that return 4xx status codes”*, and that URLs previously indexed which begin returning 4xx are removed from the index. A blocked article is not merely unread. It is actively withdrawn.

Finding this class of rule — the one that blocks something you meant to publish — is part of what [the free check](https://visibleorigin.com/services/#free-check) looks for, by requesting your pages the way a crawler does rather than the way you do. Reviewing a whole rule set against what the site is actually for is [the Cloudflare security and performance work](https://visibleorigin.com/services/).

## The fix, and the edge the fix does not remove

Replacing one word-match with two narrower tests:

| Path | Current clause | Narrowed clauses |
| --- | --- | --- |
| `/phpinfo.php` | blocks | blocks |
| `/admin/phpinfo.php` | blocks | blocks |
| `/phpinfo.php.bak` | blocks | blocks |
| `/what-phpinfo-hides/` | **blocks** | passes |
| `/phpinfo/` | **blocks** | passes |
| `/phpinfo` (no trailing slash) | blocks | **still blocks** |

Same 34 probe paths caught. The last row is the residue, and it is not zero.

That last row is the honest part. This site uses post-name permalinks, so an article whose slug is exactly `phpinfo` lives at `/phpinfo/` with a trailing slash, and WordPress answers a request without the slash with a 301 to the canonical form. **Under the narrowed rule that redirect never happens**, because the block fires first and returns 403 to a request that would have been a redirect.

It is a narrow case: it needs a slug that is exactly the probe word, reached without a trailing slash. But it exists, I cannot remove it with the operators available on this plan, and writing “fixed” without mentioning it would be the same kind of rounding-up this whole note is about.

## What I am doing instead of a perfect rule

Since the residue cannot be engineered away here, it becomes a habit rather than a rule: **before publishing, run the slug past the clauses.** It is a one-line check against a list that changes once a year, and it catches the case at the only moment when changing the slug is free.

That is less satisfying than a rule with no edges, and it is what the plan allows. The alternative — paying for regex so the rule can express *“phpinfo as a filename, not as a word”* — is a real option and an honest one, and it is worth knowing that is what the upgrade would buy, rather than discovering it after publishing an article nobody can read.

The measurement behind the rule, and the limits of what it can tell you, are written out in [how I measure](https://visibleorigin.com/how-i-measure/). If you want your own rules read against what your site publishes, [send me a domain](https://visibleorigin.com/contact/).

## Firewall rules and your own content: frequently asked questions

### Can a WAF rule block my own pages?

Yes, and the most likely cause is a clause that matches a word rather than a structure. Rules that match dotfiles, directories or exact filenames are safe because no article is published at such a URL. A rule matching a term you write about is not.

### Why not just use a regular expression?

Because it may not be available on your plan. Cloudflare's table lists regex support as unavailable on Free and Pro. Without it, you build from `contains`, `starts_with`, `ends_with` and wildcard matching, and those cannot express “this word, but only as a filename”.

### Is `contains` case-sensitive?

Yes. Wrap the field in `lower()` or a request with different capitalisation bypasses the clause entirely.

### How would I notice my rule blocking an article?

Not from the admin, the preview or the sitemap, which all look correct. Request the published URL from outside, without your session, the way a crawler would — the same discipline that showed me [four in ten bot requests were not from who they claimed](https://visibleorigin.com/ai-crawlers-verified-by-ip/). That single habit catches this and a family of related problems.

### What happens to a page that starts returning 403 to crawlers?

Google's documentation says content from URLs returning 4xx is not used, and that previously indexed URLs returning 4xx are removed from the index. So the cost is not just that the page fails to rank — it is withdrawal of something that was already there.

### How do I tell whether a 403 came from my CDN or my server?

Cloudflare says a 403 without their branding on the page is coming from the origin. If the block page is theirs, the request never reached your server.

## The general shape of this

The rule was checked, and the check was good, and it could not have found this. That is the part I want to keep: **a validation that only looks at what exists cannot see a conflict with what you have not made yet**, and most validation only looks at what exists.

I do not have a general technique for that. What I have is a smaller claim: when a rule matches on a word instead of a shape, stop and ask whether that word is one your site might reasonably use. It costs one question per clause. In my case eleven clauses passed it instantly and the twelfth did not, which is roughly the ratio I would expect anywhere.

And the fix having its own edge is not a failure of the fix. It is what precision looks like when you keep going past the point where it feels finished — and the point where it feels finished is usually one step too early.
