Safety release 2.3.2 — safe to run on a stranger's site - #54
Conversation
The plugin is live on wordpress.org and, on the modal SMB stack, could take a site offline from a single anonymous request. This release deliberately makes it do less by default. Monitor mode is now the default (webdecoy.php:230). Everything is detected, logged and reported; nothing is blocked, throttled or 403'd until the owner turns blocking on. suppression_reason() is the single gate, returning 'disabled' | 'monitor' | 'proxy' | '', and every automatic block now routes through enforce_block(). Fixes #48 — proxy IP collapse. behind_cloudflare defaults false and trusted_proxies defaults empty with no auto-detection, so behind a reverse proxy SignalCollector::getIP() correctly distrusts the spoofable forwarding headers and returns REMOTE_ADDR — which is the proxy. Every visitor collapsed to one address, and one hostile request could block them all for 24h. proxy_misconfigured() now detects the mismatch and withholds all IP-keyed action while it holds. getIP() is deliberately unchanged: trusting those headers would let any client choose its own identity and frame a third party into the block table. Fixes #49 — Blocker::block() validated only IP syntax. guard() now refuses loopback, private/reserved space, a configured trusted-proxy range, and the current request's proxy front door, and caps automatic CIDR blocks at /24 (v4) and /48 (v6). Refusals are recorded and surfaced. New $force param bypasses the guards and is set only where a human typed the address. Fixes #50 — WEBDECOY_DISABLE recovery constant, one global monitor-mode switch above the three existing dry-run flags, and admin notices that say plainly when the plugin is watching rather than acting. Fixes #51 — block_duration default 24h -> 1h (93% of hostile addresses are gone within the hour). The honeypot path passed no duration at all, so it blocked permanently. expires_at was written in UTC and compared against site-local time, so on any site at UTC+1 or further east a short block was already expired when written and blocking silently did nothing; the block table is now UTC end to end, including the statistics page's MySQL NOW() comparison and the admin display. Fixes #52 — WooCommerce counted completed orders as card testing. Three paid sub-$5 orders in an hour, the normal profile for digital downloads and donations, read as an attack. Successful orders no longer count toward card-testing patterns or the checkout velocity limit, and the checkout path refuses the order and logs rather than writing a sitewide IP block. Refs WebDecoy/app#476 — the cross-site actor feed no longer writes to the block table. Only 2 of 4,866 addresses were ever seen at more than one site, 82% of feed entries were already a week stale and none were still active, so blocking on it mostly hit whoever holds the address now. It is advisory intelligence, and maybe_upgrade() removes the rows it previously wrote. 75/75 tests pass, phpcs clean, phpstan clean apart from one pre-existing issue in class-webdecoy-updater.php which this change does not touch.
proxy_misconfigured() inferred "there is a proxy in front of us" from X-Forwarded-For / CF-Connecting-IP / X-Real-IP on the CURRENT request. Those headers are client-controlled, so on any install without a configured trusted proxy — the default, and the correct configuration for an ordinary host — a visitor could send one header and have suppression_reason() return 'proxy', switching the entire enforcement stack off for their own request. That is strictly worse than the bug the state was added to contain. The signal is now a stored flag written only from an authenticated administrator's request (maybe_flag_proxy() on admin_init, gated on manage_options), which is a trustworthy sample of how traffic reaches the site and cannot be forged by a visitor. Front-end code reads the flag; nothing on a front-end path reads the headers. Blocker::guard()'s proxy-front-door refusal had the same defect — an attacker reaching the origin directly could send X-Forwarded-For and make guard() refuse to block them. It now keys off the same stored flag. The header list is also widened (True-Client-IP, Forwarded, X-Sucuri-ClientIP, Fastly-Client-IP, Incap-Client-IP, X-Cluster-Client-IP). It now drives only the admin-side detection, where a false positive costs a notice and a false negative costs the backstop, so breadth is the safe direction. Also gates the two WooCommerce is_blocked() checks on the suppression state and removes the sitewide block writes from the Store API checkout path, which the classic path had already lost. Found by the adversarial review that was re-run after the first attempt lost 10 of 11 agents to a session limit. Thirteen further blockers from that review are NOT fixed here and are filed separately; the release stays blocked.
⛔ Do not merge. Do not release. The review has run and it found 14 blockers.Converted to draft. The adversarial review that died on a session limit was re-run Fixed on the branch (e10098f)A complete enforcement bypass that this PR introduced. That made the branch strictly less safe than the 2.3.0 it was meant to protect, so it could not Blocking, filed
The honest readThe individual fixes in this PR are mostly right. What is wrong is the premise that a single Recommended: fix #56, #57, #58 and #59 on this branch, re-run the review, and only then consider |
…58 #59) The release's premise was that one gate in handle_rule_decision() covered enforcement. It did not: the plugin acts on requests from six other places, none of which routed through it, so monitor mode and WEBDECOY_DISABLE were promises the code did not keep. #56 WooCommerce checkout, classic and Store API. The velocity, card-testing and bot-score refusals aborted the order with no gate. An error notice in woocommerce_checkout_process stops order creation, so on an offline-payment store (COD/BACS/Cheque never call payment_complete(), so no attempt row is ever marked success) six orders in a rolling hour killed the checkout — while the banner read "nothing is blocked" and monitor mode, enabled=false and WEBDECOY_DISABLE all failed to stop it. Detection and logging are now unconditional and only the notice/RouteException is withheld. catch_coupon() was also writing a real sitewide block from a honeytoken hit in monitor mode; rejecting the coupon is kept, since a code that does not exist answering as though it does not is the deception working, not enforcement. #57 The monitor_mode default was merged in at runtime but never persisted, so the settings form — which reads the stored array directly — rendered the checkbox unchecked on every upgraded install. An unchecked box submits nothing and sanitize_options() rebuilds from scratch, so saving ANY setting silently turned full enforcement on. maybe_upgrade() now persists the key, the activator seeds it for fresh installs, and the render site defaults to checked when it is absent. #58 check_login() returned a WP_Error with no gate and no WEBDECOY_DISABLE check, so the kill switch this release documents as "you can always recover over FTP" could not recover a login lockout. Gated, along with filter_comment() (a literal 403, reachable at default sensitivity) and check_registration(). check_canary_login() still refuses in monitor mode — a canary credential is zero-false-positive evidence — but now honours WEBDECOY_DISABLE, because a kill switch that has exceptions is not one. #59 serve_challenge_page() sends a 403 interstitial and exit(); only the sibling block branch was gated. Now gated after the verified-cookie check, so a visitor who already solved the challenge is not counted as a withheld action. Withheld actions feed record_suppressed_action(), so the monitor-mode counter reflects what enforcement would have done rather than staying at zero. 75/75 tests, phpcs clean, phpstan clean apart from the pre-existing class-webdecoy-updater.php issue.
Closes #48, #49, #50, #51, #52. Refs WebDecoy/app#476.
The plugin is live on wordpress.org and, on the modal SMB stack (WordPress behind
Cloudflare or a managed host), could take a site offline from a single anonymous
request. This release deliberately makes the plugin do less by default.
The headline behaviour change
Monitor mode is now the default (
webdecoy.php:230), for new installs andexisting ones —
load_options()doesarray_merge($defaults, $saved), andmonitor_modeis a new key, so a saved options array does not override it.Everything is still detected, logged and reported to the cloud. Nothing is blocked,
throttled or 403'd until the owner turns blocking on.
suppression_reason()is the single gate —'disabled' | 'monitor' | 'proxy' | ''—and the four automatic block sites now route through
enforce_block()rather thancalling
Blocker::block()directly.What each fix actually does
proxy_misconfigured()detects "forwarding header present, no trusted proxy configured" and withholds every IP-keyed action while it holds, with a blocking admin notice naming the header seen.Blocker::guard()refuses loopback, private/reserved space, a configured trusted-proxy range, and the current request's proxy front door; automatic CIDR blocks capped at /24 (v4) and /48 (v6). Refusals recorded viaget_refusals()and awebdecoy_block_refusedaction.WEBDECOY_DISABLErecovery constant checked inearly_check(); one global monitor-mode switch above the three existing dry-run flags; admin notices for all three suppressed states.block_duration24h → 1h; honeypot path now passes a duration (it previously passed none, so it blocked permanently); block table is UTC end to end.maybe_upgrade()purges the rows it previously wrote.getIP()is deliberately unchangedsdk/src/SignalCollector.php:127-170is correct. With no trusted proxy configuredit distrusts
CF-Connecting-IP/X-Real-IP/X-Forwarded-Forand returnsREMOTE_ADDR. Trusting those headers would let any client choose its own identity,evade every block by rotating a header, and frame an arbitrary third party into the
block table — strictly worse than the bug being fixed. The defect was that nothing
detected the configuration mismatch.
Timezone fix, in full
expires_atwas written withgmdate()(UTC) and compared againstcurrent_time('mysql')(site-local), so on any site with a UTC offset of +1 orgreater a short block was already expired the moment it was written — blocking
silently did nothing there. Converted: all reads and writes in
class-webdecoy-blocker.php, theNOW()comparison instatistics-page.php:106(MySQL
NOW()resolves in the DB session time zone, which WordPress never sets),and the admin display in
blocked-ips-page.phpnow goes throughget_date_from_gmt()so it does not show UTC labelled as local.
Checks
php tests/run.php)phpcs0 errorsphpstanclean except one pre-existing error inclass-webdecoy-updater.php:76, untouched hereReview status — read before merging
The adversarial review I ran over this diff did not complete: 5 of 6 review lenses
and all 5 verification agents died on a session limit. Three findings from the one lens
that finished were checked by hand and fixed in this PR (the
NOW()comparison, theadmin date display, and an option write per throttled request that would have turned a
bot flood into an options-table flood).
Two findings remain unverified, both pre-existing and out of scope here — filed
separately rather than fixed in a safety release:
checkout_attempts.created_atis written withcurrent_time('mysql')and read with agmdate()threshold — the same class of bug as Block durations are wrong in three different directions: 24h by default, permanent from the honeypot, and zero on non-UTC sites #51, in the Woo table.gmdate()againstdetections.created_at, written site-local.The remaining lenses — gate completeness across paths that bypass
early_check(),guard()edge cases (IPv4-mapped IPv6, CGNAT, load order), the Woo Store API path, andthe upgrade/defaults merge — have not been reviewed by anything other than the author.
Worth a second pass before this goes to wordpress.org.