cfwr.sh
Bash scripts to create and manage Cloudflare WAF rules for WordPress sites through the Cloudflare API. Supports batch operations across multiple zones.
cfwr.sh is the entry point. It runs one of two scripts:
| Script | What it is |
|---|---|
cfwrv1.sh | The stable CLI: the released command set, backed by inc/. |
cfwrv2.sh | The single-file refactor, still in progress. It covers the core WAF and profile commands. |
Choosing a version
./cfwr.sh -d example.com -c create-rules default # asks, on a terminal
./cfwr.sh -v1 -d example.com -c create-rules default # force the stable CLI
./cfwr.sh -v2 -d example.com -c list-rules --json # force the refactor
CWR_VERSION=2 ./cfwr.sh list-profiles # same, for scripts
- On a terminal it prints a short banner explaining that v2 is a refactor in
progress, then asks
Version [1]:. Enter selects v1;2selects v2. - In a pipe, a cron job, a CI run or any non-interactive shell it does not ask:
it runs v1 and prints one stderr line naming
-v2. -v1/--v1and-v2/--v2may appear anywhere in the arguments; the flag is removed before the chosen script sees the rest.- The chooser's own banner and menu go to stderr, so
./cfwr.sh -v2 … --json | jqstays clean.
Both scripts stay runnable directly — ./cfwrv1.sh … and ./cfwrv2.sh … behave
exactly as cfwr.sh -v1 … and cfwr.sh -v2 … without the chooser. The short
symlinks cloudflare-wordpress-rules and cfwpr point at cfwr.sh.
v1 usage
cfwrv1.sh -d <domain> -c <command> [options]
RULES COMMANDS (Rulesets API)
create-rules <profile> Create rules on domain using profile
update-rules <profile> Update rules on domain using profile
upgrade-default-rules Upgrade MWP default rules on domain
list-rules List rules on domain
delete-rule <id> Delete specific rule by ID
delete-rules Delete all rules on domain
PROFILE COMMANDS
list-profiles List available rule profiles
print-profile <profile> Print rules from profile
validate-profile <profile> Validate profile JSON syntax
RULESET COMMANDS
list-rulesets List rulesets on domain
get-ruleset <id> Get specific ruleset by ID
get-ruleset-fw-custom Get http_request_firewall_custom ruleset
ruleset-get-entrypoint Get the WAF custom rules entry point
ruleset-add-rule <json> Add a single rule to the entry point
ruleset-update-rule <id> <json> Update a single rule in the entry point
ruleset-delete-rule <id> Delete a single rule from the entry point
migrate-to-rulesets [--delete-old] Migrate old Firewall Rules to Rulesets API
FILTER COMMANDS (DEPRECATED since 2025-06-15)
list-filters List filters on domain
get-filter <id> Get specific filter by ID
delete-filter <id> Delete specific filter by ID
delete-filters Delete all filters on domain
SETTINGS COMMANDS
get-settings Get security settings on domain
set-settings <setting> <value> Set security setting
Settings: security_level, challenge_ttl, browser_integrity_check, always_use_https
AUTH COMMANDS
list-auth-profiles List available authentication profiles
OPTIONS
-d, --domain <domain> Domain to operate on (can be used multiple times)
-zf, --zones-file <file> Load zones from file (one per line)
-y, --yes Skip confirmation prompt for multi-zone ops
-c, --command <cmd> Command to execute
--cf-profile <name> Cloudflare auth profile from .cloudflare
--cf-auth-profile <name> Alias for --cf-profile
--config <file> Credentials file instead of ~/.cloudflare
--challenge-ttl <seconds> Challenge Passage TTL (skips the interactive prompt)
--debug Enable debug mode
--json list-rules: one compact JSON object per zone
--csv list-rules: CSV rows including expressions
--raw list-rules: unmodified entry point response
--long list-rules: detailed block per rule
--table-only DEPRECATED: alias for the default table
-dr, --dryrun Dry run, don't send to Cloudflare
-h, --help Show this help and exit
Examples
# Create rules on a single domain
cfwrv1.sh -d domain.com -c create-rules default
# Create rules and force a specific auth profile
cfwrv1.sh -d domain.com -c create-rules default --cf-profile PROD
# List rules on a domain
cfwrv1.sh -d domain.com -c list-rules
# List rules as one JSON object per zone (pipe to jq)
cfwrv1.sh -d domain.com -c list-rules --json
# List rules as CSV, including expressions
cfwrv1.sh -d domain.com -c list-rules --csv > rules.csv
# Show each rule's logging, action parameters and full expression
cfwrv1.sh -d domain.com -c list-rules --long
# Print the unmodified Cloudflare entry point response
cfwrv1.sh -d domain.com -c list-rules --raw
# Deprecated alias for the default table (kept for existing scripts)
cfwrv1.sh -d domain.com -c list-rules --table-only
# Delete a specific rule
cfwrv1.sh -d domain.com -c delete-rule 1234567890
# Get security settings
cfwrv1.sh -d domain.com -c get-settings
# Set security level
cfwrv1.sh -d domain.com -c set-settings security_level high
# One-time migration from old Firewall Rules to the Rulesets API
cfwrv1.sh -d domain.com -c migrate-to-rulesets
# Migration + cleanup of old rules
cfwrv1.sh -d domain.com -c migrate-to-rulesets --delete-old
Challenge Passage TTL
Challenge Passage is how long a visitor who passed a challenge is exempt from
being challenged again. --challenge-ttl <seconds> sets it on the zone before
the rules are created, which skips the interactive prompt that create-rules
would otherwise show:
cfwrv1.sh -d domain.com -c create-rules default --challenge-ttl 3600
Three things worth knowing:
- The value must be one of Cloudflare's allowed TTLs —
300,900,1800,2700,3600,7200,10800,14400,28800,57600,86400,604800,2592000,31536000. Anything else is rejected with the list printed. - It only ever raises the setting. If the zone already has an equal or longer TTL, the command leaves it alone and says so.
- It runs as part of
create-rulesonly. To change the setting on its own in v1, use-c set-settings challenge_ttl <seconds>; v2 exposes it throughcreate-rules --challenge-ttlonly.
list-rules output formats
list-rules writes data to stdout and diagnostics (errors, warnings, --debug) to stderr, so every format can be piped safely. A zone with no rules is a success: it exits 0 with an empty result.
Table (default)
$ cfwrv1.sh -d example.com -c list-rules
example.com · 8e88ca76443f8e20af9c78042803b58d
default · http_request_firewall_custom · 4 rules · version 12 · updated 2026-10-16 20:32:35
# ACTION ENABLED RULE ID UPDATED DESCRIPTION
1 skip yes eb107ee2c59741a581214b3e359fffc5 2026-10-16 20:32:35 R1V207 - Allow URI Query, URL, User Agents...
2 block yes a8d7034442ea4b76aad7a48b5454dc38 2026-10-16 20:32:35 R2V207 - Block URI Query, URL, User Agents...
3 managed_challenge yes 85d6b0bce6fa4445bc7d1a2ad87a913c 2026-10-16 20:32:35 R3V207 - Managed Challenge /wp-admin...
4 js_challenge yes df7e5f803cf845d7858ea7737b084691 2026-10-16 20:32:35 R4V207 - Challenge Outside of GEO...
UPDATED is the per-rule last_updated Cloudflare reports, shown in UTC as
YYYY-MM-DD HH:MM:SS (? when a rule carries no timestamp); the summary line
above the table shows the ruleset's own last_updated in the same format.
Cloudflare does not expose a creation date for a rule.
--long replaces the table with a detail block per rule: id, enabled state,
logging, last_updated, action parameters and the full expression.
--table-only is a deprecated alias for the default table.
JSON
One compact object per zone — domain, zone_id, ruleset (id, name, phase, version, last_updated, rule_count) and the rules array exactly as Cloudflare returns it:
cfwrv1.sh -d example.com -c list-rules --json | jq '.rules[].action'
For several zones, each zone prints its own object; combine them with jq -s:
cfwrv1.sh -zf zones.txt -c list-rules --json | jq -s 'map({domain, count: (.rules | length)})'
CSV
A single header row per run, then one row per rule:
domain,zone_id,position,rule_id,action,enabled,logging,description,expression.
Fields are quoted by @csv, so commas and quotes in expressions and descriptions stay in one field.
cfwrv1.sh -d example.com -c list-rules --csv > rules.csv
Raw
--raw prints the Cloudflare entry point response unchanged, including its success, errors and messages envelope. Use it when debugging the API response itself.
Applying rules prints the same table
create-rules, update-rules and upgrade-default-rules reuse the table above
instead of dumping the API response:
- Before replacing — the command prints
This zone already has N rule(s), the existing rules table (count, rulesetupdatedline, per-ruleUPDATED) andThe profile contains N rule(s), all on stderr, then asksReplace all existing rules? [y/N]. - After applying — the resulting rules table goes to stdout, preceded by a
counted success line such as
Replaced 4 rule(s) in ruleset <id>. A newly created entry point printsCreated entry point ruleset <id> with N rule(s). -y/--yesskips the prompt and the existing-rules preview; the resulting table still prints.
Every request failure still exits non-zero and reports the Cloudflare error. In v1 stdout carries the table; in v2 the table is human output and goes to stderr (see v2 differences).
v2 differences
cfwrv2.sh implements the core workflow in one self-contained file, with all
human output going through the vendored
bash-ui library. The full contract is
on the v2 script page.
Commands: create-rules, update-rules, upgrade-default-rules, list-rules,
delete-rule, delete-rules, list-profiles, print-profile,
validate-profile.
What changes against v1:
v1 (cfwrv1.sh) | v2 (cfwrv2.sh) | |
|---|---|---|
| Command form | -d <domain> -c <command> | Both that and cfwrv2.sh <command> … -d <domain> |
list-rules table, validation report, prompts | stdout | stderr, always |
| stdout | mixed | data only: list-rules --json/--csv/--raw, print-profile |
delete-rule with -y | still prompts | skips the prompt |
-dr / --dryrun | warns, then continues | exits 2 with a message |
| Not available | — | filters, ruleset administration, migrate-to-rulesets, get-settings/set-settings, list-auth-profiles, token and Turnstile |
The API traffic is unchanged: for every command v2 implements it sends the same
Rulesets requests, with the same bodies, as v1. tests/test-cfwrv2.sh asserts
that by running both scripts against the same fixture and comparing the captured
request bodies.
Profiles
Profiles are JSON files in profiles/. The profile selector is resolved by
filename first (profiles/<selector>.json), then by the profile's internal
name field. That means default always targets the latest numbered release,
and any name shown by list-profiles is also a valid selector.
default— symlink to the latest numbered release.mwp-rules-beta— staging profile for rule changes.mwp-rules-v<NNN>— versioned release snapshots.
The full schema is documented in the profile format reference. Rule changes follow the profile management workflow.