=== ProbeGuard 404 Firewall for Cloudflare ===
Contributors: dandawson
Tags: cloudflare, firewall, 404, block bots, brute force
Requires at least: 5.3
Tested up to: 7.1
Requires PHP: 7.2
Stable tag: 2.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Monitors 404 requests and blocks or challenges abusive IPs at the Cloudflare edge using IP Access Rules.

== Description ==

ProbeGuard records 404 requests and, when a single IP exceeds a configurable threshold within a time window, adds a Cloudflare IP Access Rule for that address. The rule is removed automatically when the block duration expires. Access Rules are available on every Cloudflare plan, including Free.

Because the block happens at Cloudflare, abusive traffic stops before it reaches PHP.

**Features**

* Configurable threshold, time window and block duration
* Repeat offenders are re-blocked on their next 404 and each block lasts twice as long, up to a configurable ceiling
* Choose Block, Managed Challenge or Interactive Challenge for abusive IPs
* Blocks are placed by a scheduled task, never during the visitor's request
* Anchored whitelist with wildcard support, matched against path, query string and host
* Status screen with top offenders, recent 404s, active blocks and recent warnings
* Optional email notification when an IP is blocked
* Automatic pruning of 404, block and diagnostic records
* Cloudflare credentials are entered in the plugin settings, with the token masked once saved, or defined in `wp-config.php` instead

**Visitor IP detection**

`CF-Connecting-IP` is trusted only when the request reaches the site from a Cloudflare edge address; the range list is refreshed daily from Cloudflare's public endpoint. If the site sits behind an additional proxy or load balancer, list its addresses in this optional constant so forwarded headers are honoured:

`define( 'EDGE404_TRUSTED_PROXIES', '10.0.0.0/8, 203.0.113.7' );`

Otherwise a request is attributed to the address it actually connected from, which cannot be forged. Private and reserved addresses are never sent to Cloudflare as block targets.

== External services ==

This plugin communicates with the Cloudflare API at `api.cloudflare.com`:

* Creating, listing and deleting IP Access Rules sends the offending IP address, the rule mode, an expiry note, and the configured zone ID. This happens when an IP crosses the block threshold, when a block expires or is removed manually, and when the Cloudflare block count is displayed in the admin.
* A daily request to `https://api.cloudflare.com/client/v4/ips` refreshes the list of Cloudflare edge ranges. It sends no site or visitor data and requires no credentials.

Cloudflare's terms of service (https://www.cloudflare.com/terms/) and privacy policy (https://www.cloudflare.com/privacypolicy/) apply.

== Installation ==

1. Upload the plugin folder to `/wp-content/plugins/`, or install it from the WordPress admin, then activate it.
2. Create a scoped Cloudflare API token in the Cloudflare dashboard under Manage Account -> Account API Tokens, using Create Token -> Create Custom Token, with the permission **Zone -> Firewall Services -> Edit**, scoped to the zone for this site. The Documentation tab links straight to that form with the permission already selected.
3. Copy the Zone ID from the Overview tab of that domain in the Cloudflare dashboard.
4. Paste both values into **Tools -> ProbeGuard 404 Firewall -> Settings** and save. The token is stored in the database and only ever shown masked afterwards.
5. Review the thresholds on the same tab.

Sites that would rather keep credentials out of the database can define them in `wp-config.php` instead, in which case the constants take precedence and the fields are shown read-only:

`define( 'EDGE404_API_TOKEN', 'your-cloudflare-api-token' );`
`define( 'EDGE404_ZONE_ID', 'your-zone-id' );`

Sites set up before 2.0.0 used `CF_404_API_TOKEN` and `CF_404_ZONE_ID`. Those names are still honoured, so an existing `wp-config.php` does not have to be edited: the values are copied into the settings on upgrade and the constants can be removed whenever convenient.

Cloudflare Global API Keys are not supported: they authenticate against the whole Cloudflare account, which is far more access than editing one zone's access rules needs.

== Frequently Asked Questions ==

= Does this require a paid Cloudflare plan? =

No. It uses IP Access Rules, which are available on all plans. Cloudflare enforces an account-level quota on the number of rules.

= How do whitelist patterns work? =

Each line is matched against the request path, the path plus query string, and both of those prefixed with the requested host. That means an entry can be a path (`/tracking/*`), a host and path (`tracking.example.com/tracking/*`), a "contains anywhere" pattern (`*tracking.example.com*`, which also matches that text in a path or query string) or a pasted URL. Use `*` for wildcards; a pattern with no wildcard must match exactly. Entries saved by earlier versions gain a trailing `*` on upgrade so previous prefix behaviour is preserved.

= My email click-tracking links are logged as 404s =

They are 404s, so they are logged. Hosts that rewrite links in outgoing mail serve them from a tracking subdomain, so add an entry such as `/tracking/*` or `tracking.example.com/*` to the whitelist; matching includes the requested host, so host patterns work too. Nothing host-specific is whitelisted by default.

= What happens if WP-Cron is not running? =

Blocking falls back to running inline if the scheduled event cannot be created, but expiry, log pruning and diagnostics rely on the scheduled task. Server-level cron and external schedulers work; the plugin warns only when its own maintenance task has not run for two hours, and the warning can be dismissed permanently.

= How long are logs kept? =

404 records are kept for the configured retention period (1-168 hours, 12 by default). Expired block records are removed a day after expiry. Diagnostic warnings and errors are kept for 7 days and capped at 500 rows. Credentials are redacted from diagnostic output.

= Should the token be an account token or a user token? =

Either works. An account token (Manage Account -> Account API Tokens) belongs to the Cloudflare account, so it keeps working when the user who created it loses access, which makes it the better default. A user token (My Profile -> API Tokens) is the only option for a zone reached through an account you are not a member of. One token can serve several sites if its Zone Resources include each zone.

= What happens if the same IP keeps coming back? =

It gets progressively harder to shake off. While an IP is blocked, Cloudflare handles its requests at the edge, so they never reach WordPress to be counted — which is why a returning IP is re-blocked on its first 404 instead of having to earn the full threshold again, and why each successive block within the "Remember offenders" window is twice as long as the last, up to the "Maximum block duration" ceiling. If a 404 does get through while a block is live, the existing block is extended rather than duplicated. Set "Remember offenders" to 0 to treat every block as a first offence.

= What happens when I deactivate or delete the plugin? =

Deactivating cancels every scheduled task and, unless "Keep Cloudflare blocks when the plugin is deactivated" is enabled in Settings, deletes the Cloudflare access rules the plugin created — nothing would remain to expire them otherwise. Deleting the plugin removes its rules, database tables and options.

== Screenshots ==

1. Status: Cloudflare connection state, top offending IPs and the blocks currently in place.
2. The 404 log, showing what each IP actually requested.
3. Settings: thresholds, block duration, repeat-offender handling, retention and the whitelist.
4. Documentation, with links that open the right Cloudflare screens for this site.

== Changelog ==

= 2.0.0 =
* Cloudflare credentials are entered on the Settings tab instead of `wp-config.php`. The token is stored in a non-autoloaded option, masked once saved, and never rendered back to the browser; leaving the field empty keeps the stored token.
* Constants still win when they are defined, and credentials found in `wp-config.php` on upgrade are copied into the settings so the constants can be removed at leisure.
* Renamed from CF 404 Defender to ProbeGuard 404 Firewall for Cloudflare, so nothing about the name suggests it is published by Cloudflare.
* Internal prefixes are now `edge404_` / `EDGE404_` rather than being tied to the display name. Tables, options and pending scheduled tasks are moved to the new names on upgrade, and Cloudflare rules created by 1.x are still recognised, so blocks keep expiring on time.
* wp-config.php credentials are now `EDGE404_API_TOKEN` and `EDGE404_ZONE_ID`. The old `CF_404_API_TOKEN`, `CF_404_ZONE_ID` and `CF404_TRUSTED_PROXIES` names still work and take no action to keep.
* Filters and hooks renamed to the `edge404_` prefix: `edge404_notification_subject`, `edge404_block_duration_minutes`, `edge404_block_ip_event`, `edge404_unblock_ip`.

= 1.3.8 =
* The Copy button on the wp-config.php snippet works in admin notices, including the credentials notice shown on the plugins list. The stylesheet and the script behind the button were only loaded on the plugin's own screen, so elsewhere the button did nothing and the snippet was unstyled.
* The Copy button sits below the snippet instead of overlapping its first line.

= 1.3.7 =
* Block notifications list the requests as paths rather than absolute URLs. Mail services that rewrite links for click tracking were replacing each logged URL with an opaque tracking address, so the notification no longer showed which requests earned the block.
* `/tracking/*` is no longer whitelisted by default and is no longer added to whitelists on upgrade: it only fits sites whose mail service uses a tracking subdomain, and the plugin should not edit a setting the site owner has saved. Add it manually if your host rewrites links in outgoing mail.

= 1.3.6 =
* Queries against the plugin's own tables are annotated for Plugin Check, which cannot tell that the interpolated table name comes from $wpdb->prefix and not from user input. No behaviour changes.

= 1.3.5 =
* Plugin Check no longer reports an error for the Cloudflare dashboard links, which it read as offloading assets to a remote service, and the build now fails when Plugin Check reports an error rather than only printing it.
* Installation documents account tokens, matching the plugin's own setup screen, and the Global API Key upgrade instructions are gone.

= 1.3.4 =
* Real links to Cloudflare replace the copy-and-paste path: one opens this site's IP Access Rules, one opens the zone Overview where the Zone ID lives, and one opens the account token form with Firewall Services -> Edit already selected and the token named after this site. The links use Cloudflare's ":account" placeholder, which the dashboard resolves after login, so no account ID is needed or exposed.
* Dropped two paragraphs aimed at sites upgrading from a version that authenticated with a Global API Key; a new install has no reason to read about a credential it never used.

= 1.3.3 =
* The wp-config.php snippets in the setup notice and documentation line their values up in a column and have a copy button, so they can be pasted without re-typing or re-indenting.
* Setup now documents account tokens (Manage Account -> Account API Tokens), which keep working when the user who created them loses access to the account, and explains when a user token is the right choice instead.

= 1.3.2 =
* A successful connection no longer second-guesses the credentials: token lengths vary, and a valid 37-character token was being reported as authenticated and malformed at the same time. Shape advice now appears only alongside an actual Cloudflare failure.
* A Global API Key pasted into `CF_404_API_TOKEN` is recognised and named, since Cloudflare's own answer ("Invalid request headers") does not hint that the wrong kind of credential was used.
* The Documentation tab highlights the exact Cloudflare labels to click when creating a token.

= 1.3.1 =
* Credentials defined with surrounding whitespace or quotes, or with the `Bearer ` prefix included in `CF_404_API_TOKEN`, are now cleaned up instead of producing a Cloudflare "Invalid request headers" error.
* When Cloudflare rejects the credentials, the recorded error and the Status tab explain what to check rather than repeating Cloudflare's wording. A token or zone ID with the wrong shape is named specifically, and a zone Cloudflare cannot route to says so — an Account ID and a Zone ID are indistinguishable by eye.
* The Cloudflare connection state is shown as an indicator on both the Status and Settings tabs, and names the zone the token was accepted for, so a successful setup is visible without waiting for a block.

= 1.3.0 =
* Repeat offenders escalate: an IP blocked within the "Remember offenders" window (24 hours by default) is re-blocked on its very next 404 rather than having to pass the threshold again, and each successive block lasts twice as long, up to the new "Maximum block duration" setting (24 hours by default). Set the window to 0 for the old behaviour.
* A 404 that gets through while a block is live pushes the expiry out instead of being ignored. Extending only reschedules the plugin's unblock task, so the Cloudflare rule is never deleted and recreated.
* The per-IP lock now lasts five minutes rather than the whole block, which is what allows a live block to be extended.
* Expired block records are kept for as long as an IP counts as a repeat offender, since that history drives the escalation.
* Added the `cf404_block_duration_minutes` filter for sites that want their own escalation curve.

= 1.2.1 =
* An IP that Cloudflare already has a rule for is no longer logged as an API error; the plugin recognises `duplicate_of_existing`, and says so at info level, whether the existing rule is one of ours or came from elsewhere in the zone.
* Recorded warnings and errors can be cleared from the Status tab; "Clear logs" is now "Clear 404 log" and only clears 404 records.
* Saving settings shows a confirmation notice and returns to the Settings tab.

= 1.2.0 =
* Security: `CF-Connecting-IP` and forwarded-for headers are only trusted from Cloudflare edge addresses or proxies listed in `CF404_TRUSTED_PROXIES`; private and reserved addresses are never blocked.
* Security: whitelist matching is anchored, so a whitelisted string in a query string no longer exempts a request from monitoring.
* Whitelist entries are also matched against the requested host, so host patterns such as `*tracking.example.com*` work; `/tracking/*` is whitelisted by default so email click-tracking links are not counted.
* Block notification subjects now read `[CF 404 Defender: example.com] Blocked 1.2.3.4 (managed_challenge, 10 min)` and are filterable via `cf404_notification_subject`.
* Security: Cloudflare calls moved off the visitor request path, with a per-IP lock to prevent duplicate blocks during a flood.
* Security: the cron-warning dismissal endpoint requires a nonce; all admin actions run through `admin-post.php` with capability and nonce checks.
* Security: credentials are redacted from diagnostic output, and API payload dumps were removed.
* Security: Global API Key authentication (`CF_404_API_KEY` / `CF_404_API_EMAIL`) was removed in favour of scoped API tokens. Sites still using it get an admin error explaining the change.
* Numeric settings are range-checked when saved instead of accepting any value.
* Diagnostic logging is stored in a pruned, size-capped table instead of growing unbounded.
* Deactivation now cancels per-IP scheduled events and removes the plugin's Cloudflare rules by default; a new setting keeps them instead.
* Added `uninstall.php` to remove rules, tables and options on deletion.
* Database schema is versioned and migrated once, instead of being inspected on every 404.
* Cloudflare rule listings are cached and invalidated whenever the plugin changes a rule.
* Unblocking matches rules on the normalised address, so a rule is never deleted twice.
* `cf404_block_ip()` returns `true` or a `WP_Error`, and a failed API call is no longer logged as a successful block.
* Saving settings no longer changes options that were not submitted.
* Admin JavaScript and CSS moved to enqueued asset files, loaded only where needed.
* Added translation support, and rewrote the documentation to match actual behaviour.

= 1.1.3 =
* Fixed documentation link navigation by adding a hashchange listener.

= 1.1.0 =
* Settings page refactored to a single form and nonce.

= 1.0.9 =
* Fixed Cloudflare unblock so rules are removed from all endpoints.

= 1.0.8 =
* Unified database logging.

== Upgrade Notice ==

= 2.0.0 =
The plugin is now ProbeGuard 404 Firewall for Cloudflare. Settings, logs and active blocks carry over automatically, and existing wp-config.php constants keep working. Sites that call the plugin's filters directly need to switch them to the `edge404_` prefix.

= 1.3.0 =
Blocks now escalate for repeat offenders (doubling, capped at 24 hours) and a returning IP is re-blocked on its first 404. Review "Remember offenders" and "Maximum block duration" in Settings if you want the previous fixed-length behaviour.

= 1.2.1 =
Removes the "duplicate_of_existing" Cloudflare errors from the diagnostic log and adds a button to clear recorded warnings and errors.

= 1.2.0 =
Security and reliability release. Cloudflare Global API Key authentication is removed — define CF_404_API_TOKEN before updating. Whitelist entries are migrated to anchored matching automatically, and deactivation now removes the plugin's Cloudflare rules unless you opt out in Settings.
