Site edition
Case 001. Cloudflare Crawler Surface Policy: Controlling Bot Access to URLs
The Great and Terrible Cloudflare series. Cloudflare Operations — policy, security and fleet management.
How can a store close transactional URLs to crawlers, retain access to public content, and verify the result across a fleet of sites? This case describes a solution that developed from a particular production incident and client infrastructure.
According to the author’s account, the fleet covered 22 sites with a combined daily audience of approximately 400,000 unique users. These figures provide author-supplied context; the aggregate metrics and pilot results below have not undergone an independent audit. Client domains, credentials, and private snapshots are excluded from publication.
The management tools initially ran on the client’s shared hosting and were written in PHP: the environment already existed alongside the sites, and the operator needed no separate server. A new independent installation can use a local environment such as WSL. The architectural principle remains consistent: configuration and policy have a reviewable state, secrets and private overlays stay local, and Cloudflare changes pass through a separate controlled write path.
Standalone code and examples: IRONCREED/cloudflare, under Case 001. Every instruction below uses that repository’s structure.
A useful bot on a transactional surface
On a WooCommerce site, crawler traffic repeatedly requested routes and parameters serving the cart, checkout, and customer account:
/cart/
/checkout/
/my-account/
?add-to-cart=
?remove_item=
?wc-ajax=These requests can invoke PHP, WooCommerce, sessions, and cart operations. Meta-WebIndexer was one conspicuous participant. Globally blocking that User-Agent would also remove its access to product pages and articles that may have search and linking value.
Observed load opens two options: a global identity block or a restriction on a particular surface; this case selects the latter.
- source: Crawler on transactional URLs
- boundary: Global identity block
- state: Surface restriction
- state: Public content outside the rule
- incident references global
- incident derives surface
- surface produces content
The decision unit becomes crawler identity × request surface × profile × zone configuration.
Identity, URL class, profile, and local conditions converge in the zone’s effective policy.
- state: Crawler identity
- state: Request surface
- state: Profile
- state: Zone conditions
- artifact: Effective policy
- identity requires effective
- surface requires effective
- profile requires effective
- zone requires effective
Three URL classes
| Surface | Examples | Decision in this case |
|---|---|---|
| Public content | Homepage, product, category, article | This policy leaves the request outside its block rule |
| Transactional hard-deny | Cart, checkout, account, operational query parameters | The selected crawler class is blocked at the edge |
| Combinatorial | Filters, faceted navigation, internal search, parameter combinations | Hostname-specific SEO review before a policy change |
The example paths match /cart, /checkout, /my-account, and descendants with a / after the name. A similar prefix such as /cartography is outside that list. Translated WooCommerce slugs, language prefixes, URL encoding, and query behaviour require testing on each actual site.
The demonstration rule searches for add-to-cart=, remove_item=, and wc-ajax= as query substrings. This simple example may also match another parameter’s value. Its matching precision needs review before production use. Filters and search remain outside the shared example: a useful SEO landing page on one store may resemble an unnecessary combination on another.
Identity and scope
Using all of cf.client.bot as this case’s crawler list would be too broad. Verified automation also includes monitoring, security checks, and other services. The demonstration uses narrower cf.verified_bot_category values:
Search Engine Crawler
AI Search
AI Crawler
Page PreviewIt adds User-Agent fallbacks for Meta-WebIndexer, Googlebot, and Bingbot. These strings can be spoofed: a fallback classifies a request but does not authenticate its origin. As checked on 6 September 2026, Cloudflare retains these legacy categories for WAF compatibility, while its new taxonomy classifies bot behaviour differently. Verified bots
A request is blocked only when both the selected crawler identity and the transactional surface match.
- state: Crawler identity match
- state: Transactional surface match
- decision: Both conditions
- boundary: Block at edge
- state: Outside this block rule
- identity requires and
- surface requires and
- and rejects block: Yes
- and returns other: No
| Request | Result of Crawler Surface Policy itself |
|---|---|
Meta-WebIndexer → / | Not blocked by this rule |
Meta-WebIndexer → /product/... | Not blocked by this rule |
Meta-WebIndexer → /cart/ | Block |
Meta-WebIndexer → /checkout/ | Block |
Other Cloudflare rules and the origin can change the final response. Failure to match one block rule does not create a global allow.
Why the edge
The restriction could be implemented in WordPress, Apache, nginx, or the application. Choosing Cloudflare places rejection before the origin: a crawler matching the defined transactional surface should not invoke WooCommerce. Custom Rules execute in their own ruleset-engine phase. Cloudflare Custom Rules
The initial production model combined a global baseline, site profile, and hostname exceptions. The portable tool generalises that model through explicit policy composition.
Profile policies, additional policies, disabled policies, and inline rules determine logical rules; the compiler turns them into physical rules.
- state: Profile policies
- state: Additional policies
- boundary: Disabled policies
- state: Inline rules
- state: Logical rules
- state: Physical rules
- artifact: Effective managed state
- profile produces logical
- additional produces logical
- disabled rejects logical: Remove from composition
- inline produces logical
- logical derives physical
- physical produces state
From production workspace to a standalone repository
The original shared-hosting workspace contained policies, profiles, exceptions, scripts, backups, and reports. It produced the compiler, diff, dry-run, audit, deployment, snapshots, and edge smoke. The standalone repository contains the reusable mechanism, fixtures, and demonstration configurations. Private client overlays remain outside it.
Path in IRONCREED/cloudflare | Purpose |
|---|---|
CONSTITUTION.md, governance/PROFILE.md | Adopted normative source and release-candidate profile |
LICENSE.md | Licensing map for code, prose, and branding |
code-constitution/ | Normative-corpus submodule pinned to a commit |
repository-licensing-policy/ | Licensing-policy submodule pinned to a commit |
cases/001-crawler-surface-policy/bin/ | compile, plan, apply, audit, export, rollout |
cases/001-crawler-surface-policy/src/ | PHP API client, compiler, and Ruleset Manager |
cases/001-crawler-surface-policy/examples/ | Inventory, profiles, policies, and zone fixtures |
cases/001-crawler-surface-policy/tests/ | Offline checks without Cloudflare tokens |
cases/001-crawler-surface-policy/runtime/ | Local output excluded from Git |
The relocation preserves the case ID, licences, and exact submodule revisions. A provenance record identifies the source tree and subsequent changes. Repository preparation and automated checks leave the final release decision with a human.
Case 001’s boundaries
The tool manages zone-level WAF Custom Rules in http_request_firewall_custom. DNS, Workers, Zero Trust, cache rules, billing, account membership, and origin configuration remain outside this case.
Explicit inventory leads to compilation, live-state inspection, a plan preserving the unmanaged portion, a separate operator decision, and result verification.
- source: Explicit inventory
- process: Compile
- process: Read live phase
- process: Preserve unmanaged
- artifact: Plan / diff
- human-decision: Operator decision
- process: Apply and snapshots
- process: Readback and audit
- inventory derives compile
- compile derives live
- live derives preserve
- preserve derives plan
- plan derives review
- review derives apply
- apply derives verify
The files bot-example.json, country-policy.json, crawler-surface.json, named-ip-list.example.json, and scanner-user-agents.json demonstrate the mechanism’s capabilities. For example, the commerce fixture additionally includes bot-example; reproducing the pilot’s surface-only restriction requires separately reviewing and, where appropriate, removing that extra policy. The examples are not a production baseline.
Inventory, profiles, and exceptions
The inventory explicitly lists zones, phase, ownership prefix, and budget:
{
"schema_version": 1,
"phase": "http_request_firewall_custom",
"managed_prefix": "[CFOPS]",
"max_physical_rules": 5,
"zones": ["domain-01.example", "domain-02.example", "domain-03.example"]
}5 is an example parameter tied to the pilot’s conditions. Check it against actual zone limits; the budget must also count unmanaged rules. Custom Rules availability
| Profile | Policies |
|---|---|
default | scanner-user-agents |
commerce | scanner-user-agents, crawler-surface |
restricted-geo | scanner-user-agents, country-policy |
A zone configuration selects a profile and modifies its composition:
{
"zone": "domain-02.example",
"profile": "commerce",
"additional_policies": ["bot-example"],
"disabled_policies": []
}Two profile policies and one zone policy form three logical components.
- source: Commerce profile
- state: scanner-user-agents
- state: crawler-surface
- source: Zone additional_policies
- state: bot-example
- profile requires scanner
- profile requires surface
- additional produces bot
An inline rule can handle one hostname without creating another shared profile. The compiler also requires id and name:
{
"zone": "domain-03.example",
"profile": "restricted-geo",
"inline_rules": [
{
"id": "forum-example",
"name": "Forum hostname example",
"action": "managed_challenge",
"physical_group": "challenge",
"expression": "(http.host eq \"forum.domain-03.example\" and ip.src.country ne \"UA\")"
}
]
}The geographic restriction demonstrates an exception mechanism. Its suitability depends on the actual site’s audience.
Logical and physical rules
PolicyCompiler collects profile policies, adds additional_policies, removes disabled_policies, and appends inline_rules. Simple rules with the same action and physical_group are joined with or into one physical rule. Their logical files remain separate.
Several logical block policies form one physical block rule; challenge remains a separate group.
- state: Logical block policy A
- state: Logical block policy B
- state: Logical block policy C
- artifact: One physical block
- state: Logical challenge policy
- artifact: Separate physical challenge
- scanner derives block
- surface derives block
- bot derives block
- challenge derives challenge-out
(crawler_surface_condition) or (bot_condition)This is an explanatory formula, not a deployable Cloudflare expression. Grouping requires compatible actions and a review of execution order. The current compiler serves the demonstrated simple rules; it does not guarantee preservation of arbitrary action_parameters in managed policies.
The managed/unmanaged boundary
The tool owns only rules whose description begins with [CFOPS]. A new managed block replaces the first previous managed rule’s position; when none exists, it is appended. Unmanaged rules retain their relative order.
The prefix defines ownership; managed rules are replaced, while the remainder enters the plan with its relative order preserved.
- source: Live rules
- decision: Description starts with prefix?
- process: Replace managed block
- process: Preserve unmanaged relative order
- artifact: Final plan
- live references prefix
- prefix executes managed: Yes
- prefix returns unmanaged: No
- managed produces plan
- unmanaged produces plan
If managed and unmanaged rules were interleaved, collecting the managed portion into one block may change their positions relative to each other. Review the plan for that change. Another writer using the same prefix breaks the ownership boundary. Sequential rollout inside one process also provides no lock against simultaneous dashboard edits or another tool.
A local WSL environment
On Windows, WSL can be installed from PowerShell:
wsl --install -d UbuntuThe command and prerequisites are described in Microsoft’s documentation. Inside Ubuntu, install Git, PHP 8.1+, and cURL:
sudo apt update
sudo apt install -y git php-cli php-curl
git clone https://github.com/IRONCREED/cloudflare.git
cd cloudflare
git submodule update --init --recursive
cd cases/001-crawler-surface-policy
php tests/run.php
cp -R examples local
export CFOPS_CONFIG_DIR="$PWD/local"
export CFOPS_RUNTIME_DIR="$PWD/runtime"local/ is excluded from Git and holds real zones and private overlays. runtime/ holds snapshots and reports. Access to normative submodules follows their own repositories’ policies; offline engine tests do not require downloading them. Copy examples during the first installation, before editing local configuration.
Creating and storing API tokens
In Cloudflare, open your profile → API Tokens → Create Token → Create Custom Token. Read operations in this case require Zone / Zone / Read for zone-ID lookup and Zone / WAF / Read for rulesets. Give a separate write token Zone / Zone / Read and Zone / WAF / Edit; it is also used for inspection before and after writing. Restrict both tokens to the required zones and, where practical, an expiry and the operator’s IP. Check permission names and availability in the current interface. Create a token, permission reference
For a single Bash session, enter the read token without displaying it or placing its value in command history:
read -rsp 'Cloudflare read token: ' CLOUDFLARE_READ_TOKEN
printf '\n'
export CLOUDFLARE_READ_TOKENEnter the write token in the same way as CLOUDFLARE_WRITE_TOKEN immediately before an approved operation, then remove it from the environment:
read -rsp 'Cloudflare write token: ' CLOUDFLARE_WRITE_TOKEN
printf '\n'
export CLOUDFLARE_WRITE_TOKEN
# Run the reviewed apply command here.
unset CLOUDFLARE_WRITE_TOKENFor reuse, use a secrets manager or a file outside the checkout, for example under ~/.config/ironcreed/cloudflare/, with directory permissions 700 and file permissions 600. Enter values through an editor or secrets manager; .gitignore cannot remove a secret already committed to Git history. Revoke and replace an exposed token. Environment variables are inherited by child processes, so load the write token only for the operation.
A read token supports observation; a write token together with an explicit decision opens the apply path.
- source: Read token
- process: Plan / audit / export
- source: Write token
- human-decision: Review and --apply
- process: API write
- read requires observe
- write requires apply
- human validates apply
Fixtures, compile, and plan
php tests/run.php checks the local compilation model without API tokens. These tests do not execute expressions at the Cloudflare edge. Compilation is also local:
php bin/compile.php --zone=domain-02.examplePlan uses the read token and an actual zone’s configuration. Replace example.com in the following commands with a zone from your own inventory:
php bin/plan.php --zone=example.comRuleset Manager resolves the zone ID, reads the phase entry point, separates managed rules, compiles desired state, constructs the final list, checks the budget, and identifies a change. It performs no write. Expression comparison preserves whitespace inside strings; a formatting difference can produce a conservative review signal.
Apply and evidence
php bin/apply.php --zone=example.com --applyWithout --apply, the CLI refuses before creating an API client. With that flag, it uses the separate write token. Before writing, the tool reads the current state again and checks the limit. An unchanged result returns NO_CHANGE.
Plan and budget checks precede the snapshot; ruleset write is followed by readback, and a mismatch stops the operation.
- process: Fresh inspection
- decision: Within budget?
- artifact: Before snapshot
- process: Ruleset write
- process: Readback / after snapshot
- decision: Expected state?
- state: Verified
- boundary: Stop with error
- inspect produces budget
- budget validates snapshot: Yes
- budget rejects stop: No
- snapshot requires write
- write produces readback
- readback validates match
- match validates success: Yes
- match rejects stop: No
A change creates a unique runtime/deployments/<timestamp-random>--<zone>/ directory containing before.json, write-response.json, and after.json. An existing entry point is updated through one ruleset operation; otherwise, a zone ruleset is created for the phase. Rulesets API
Live state is read again after the write. Remaining drift ends the operation with Post-write verification failed. A successful HTTP response alone does not establish a correct deployment. Case 001 provides no automatic rollback; restoration from a snapshot is a separate reviewed operator action.
Fleet rollout, audit, and export
| Command | Operation |
|---|---|
php bin/rollout.php | Sequential dry-run over the inventory |
php bin/rollout.php --apply | Sequential writes; an exception stops subsequent zones |
php bin/audit.php | Compare live and desired state across the inventory |
php bin/export.php | Save current phase entry points locally |
Zones are checked sequentially; changes are limited by assigned policies, and an error terminates rollout.
- source: Inventory zones
- state: Current zone
- decision: Plan: change needed?
- human-decision: Has --apply?
- process: Write and verify
- process: Next zone
- boundary: Error: stop
- inventory produces zone
- zone references plan
- plan requires review: Yes
- plan returns next: NO_CHANGE
- review validates write: Yes
- review returns next: Dry-run
- write produces next: Success
- write rejects stop: Error
Live state and compiled desired state meet in a comparison returning either agreement or divergence.
- source: Live state
- source: Compiled desired state
- process: Comparison
- state: Match
- boundary: DRIFT: review
- live references compare
- desired requires compare
- compare validates ok
- compare rejects drift
Audit reports drift and retains the read-only boundary. Snapshots, reports, and exports stay local. Traversing the entire inventory is also useful for demonstrating that most zones require no changes.
The original pilot’s results
The pilot ran on the earlier private EMBO tools before Case 001 was extracted. The following numbers come from the author’s pilot account; they are not results of a new production run of the standalone code.
| Stage | Custom Rule count |
|---|---|
| Before: baseline Block, Asia Challenge, Meta-WebIndexer hotfix | 3 |
| Remove / keep / add | 2 / 1 / 1 |
| After moving the hotfix’s function into compiled policy | 2 of the 5 available in the pilot |
| Smoke request | Recorded HTTP status |
|---|---|
Normal / | 200 |
Meta / | 200 |
Normal /cart/ | 200 |
Meta /cart/ | 403 |
Googlebot /cart/ | 403 |
Meta /checkout/ | 403 |
Meta /my-account/ | 403 |
Meta add-to-cart | 403 |
Meta remove_item | 403 |
Meta wc-ajax | 403 |
The author’s smoke summary was 10 PASS, 0 FAIL. Meta and Googlebot name test-request profiles; a substituted User-Agent alone does not test a real verified identity. The homepage’s 200 records access in that test rather than a guarantee for every future request.
| Fleet check after the pilot | Author-reported result |
|---|---|
| Audit | 22 OK, 0 DRIFT, 0 ERROR |
| Diff | 22 OK, 0 CHANGES, 0 OVER LIMIT, 0 ERROR |
| Fleet dry-run | 22 NO_CHANGE |
According to the pilot account, the commerce change was assigned to one zone; the other 21 retained their previous expected configuration. These aggregate results express the intended acceptance criterion. Independent reproduction requires your own configuration, a reviewed plan, and an edge smoke.
From pilot to portable pipeline
| Earlier tooling function | Case 001 boundary |
|---|---|
policy-compiler.php | compile.php |
diff.php | plan.php |
deploy.php | apply.php --apply |
audit.php | audit.php |
complete-export.php | export.php |
rollout.php | rollout.php, separate --apply |
validate-expression.php, crawler-surface-smoke.php | Separate deployment checks; not ported into the current Case 001 |
Local configuration and fixtures lead to compile, plan, dry-run, operator review, apply, readback, and audit; site-specific edge smoke remains a separate deployment check.
- source: Local paths and credentials
- process: Fixtures
- process: Compile
- process: Plan / diff
- process: Fleet dry-run
- human-decision: Operator review
- process: Apply / snapshot / readback
- process: Separate edge smoke
- process: Audit / export
- setup derives fixtures
- fixtures derives compile
- compile derives plan
- plan derives dry
- dry derives review
- review derives apply
- apply derives smoke
- smoke derives audit
The operational discipline follows compile → diff/plan → dry-run → apply → smoke → audit. Different commands test different claims: compilation checks the model, plan identifies divergence from live state, smoke tests request behaviour, and audit checks the expected state after a change. Readback does not replace edge smoke.
Telemetry and an analytical LLM layer
Crawler telemetry can report a new traffic class across several hostnames or a zone’s departure from its usual profile. That observation becomes a proposal for a policy revision. Authority to perform a security write remains a separate operator responsibility.
Telemetry opens a proposal; tests, plan, and human review precede explicit apply and verification.
- source: Telemetry / LLM observation
- artifact: Policy proposal
- process: Compile and tests
- artifact: Plan / diff
- human-decision: Human review
- process: Explicit apply
- process: Verification
- observe derives proposal
- proposal derives compile
- compile derives plan
- plan derives human
- human derives apply
- apply derives verify
The analytical layer receives no write token. This allows observation to be automated while changes to site availability remain explicit, reviewable actions.
Boundaries and result
The case addresses a bounded task: express crawler access through identity and surface, assign the policy to the intended zone, and check the rest of the inventory. Faceted navigation, filters, and search require separate decisions; a commerce profile is assigned only after reviewing the actual site.
The result is a shared policy-management system with different effective states for individual zones. The repository separates a reusable mechanism from client data: demonstration domains use domain-N.example, private overlays and tokens stay local, and writes follow a separate controlled path.
Case author and source of the original production account: Sam Starling. Translation, editorial adaptation, questions, public-documentation verification, and standalone-code preparation were assisted by AI. Platform sources were checked on 6 September 2026; preparing this article involved no repeat deployment to the client’s Cloudflare account.