From 6f26683210b30c374a07e8b3847b57c951b8702f Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Wed, 29 Jul 2026 16:50:46 -0500 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20safety=20release=202.3.2=20=E2=80=94?= =?UTF-8?q?=20safe=20to=20run=20on=20a=20stranger's=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- admin/partials/blocked-ips-page.php | 8 +- admin/partials/settings-page.php | 26 +- admin/partials/statistics-page.php | 11 +- changelog.txt | 15 ++ includes/class-webdecoy-actor-feed.php | 127 +++++---- includes/class-webdecoy-blocker.php | 147 +++++++++- includes/class-webdecoy-woocommerce.php | 47 ++-- readme.txt | 16 +- webdecoy.php | 340 ++++++++++++++++++++++-- 9 files changed, 615 insertions(+), 122 deletions(-) diff --git a/admin/partials/blocked-ips-page.php b/admin/partials/blocked-ips-page.php index 867c8bb..2cc28c2 100644 --- a/admin/partials/blocked-ips-page.php +++ b/admin/partials/blocked-ips-page.php @@ -25,7 +25,8 @@ $duration = intval($_POST['duration'] ?? 24); if (filter_var($ip, FILTER_VALIDATE_IP)) { - $blocker->block($ip, $reason, $duration > 0 ? $duration : null); + // force: a human typed this address, so the safety guards do not apply. + $blocker->block($ip, $reason, $duration > 0 ? $duration : null, true); echo '

' . esc_html__('IP blocked successfully.', 'webdecoy') . '

'; } else { echo '

' . esc_html__('Invalid IP address.', 'webdecoy') . '

'; @@ -126,10 +127,11 @@ - + + - + diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index 9e07b17..a3a6142 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -477,6 +477,30 @@

+ + + + diff --git a/admin/partials/statistics-page.php b/admin/partials/statistics-page.php index 32efd2c..bb062fe 100644 --- a/admin/partials/statistics-page.php +++ b/admin/partials/statistics-page.php @@ -98,10 +98,13 @@ "SELECT COUNT(*) FROM {$detections_table} WHERE created_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input gmdate('Y-m-d 00:00:00') )); -// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- static query, no user input -$active_blocks = $wpdb->get_var( - "SELECT COUNT(*) FROM {$blocked_table} WHERE expires_at IS NULL OR expires_at > NOW()" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input -); +// expires_at is stored in UTC, so it must be compared against a UTC value. MySQL's +// NOW() resolves in the database session time zone (SYSTEM by default, which +// WordPress never sets), so it disagrees with the column on any DB host not on UTC. +$active_blocks = $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$blocked_table} WHERE expires_at IS NULL OR expires_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input + gmdate('Y-m-d H:i:s') +)); // WooCommerce stats (if active) $woo_stats = null; diff --git a/changelog.txt b/changelog.txt index c5b4e54..9b97d21 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,20 @@ *** WebDecoy Bot Detection Changelog *** += 2.3.2 - 2026-07-29 = +Safety release. Please update. This version deliberately makes the plugin do LESS by default. + +* IMPORTANT — Monitor mode is now the default. WebDecoy detects, logs and reports everything, and blocks nothing, until you turn blocking on in Blocking > Monitor mode. Existing sites are switched to monitor mode by this update. Read the Detections page to see what enforcement would have done, then decide. +* Fixed: behind a reverse proxy (Cloudflare, a load balancer, most managed hosts) every visitor resolved to the proxy's address, because forwarding headers are correctly distrusted unless a trusted proxy is configured — and nothing detected that mismatch. One hostile request could put the proxy's address in the block list and 403 real visitors for 24 hours. The plugin now detects the condition, refuses to block while it holds, and says so in the admin. +* Fixed: `WebDecoy_Blocker::block()` validated only that a string parsed as an IP. It will no longer accept loopback, private/reserved ranges, a configured trusted proxy, or the current request's proxy front door from an automatic decision, and automatic blocks can no longer write a range wider than /24 (IPv4) or /48 (IPv6). Refusals are recorded and surfaced instead of failing silently. Blocks a human types in the admin are unaffected. +* Fixed: block expiry was written in UTC and compared against site-local time, so on any site with a UTC offset of +1 or greater a short block was already expired the moment it was written — blocking silently did nothing. All timestamps in the block table are now UTC. +* Fixed: a honeypot hit blocked the address permanently, because it was the one path that passed no duration. +* Changed: default block duration is now 1 hour, was 24. 93% of hostile addresses are gone within the hour, so a longer default caught almost nothing and mostly risked blocking whoever inherited the address next. +* Fixed (WooCommerce): completed orders counted as card-testing attempts. Three successful sub-$5.00 orders from one address in an hour — the normal profile for digital downloads, donations, tips and add-ons — were classified as an attack. Successful orders no longer count toward card-testing patterns or the checkout velocity limit. +* Changed (WooCommerce): the checkout path now refuses the order and records the detection, and never writes a sitewide IP block. Refusing the checkout is the proportionate response; blocking the whole site from a checkout false positive is not. +* Changed: the cross-site actor feed no longer writes addresses into your block table. Measured across the network, 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. The feed is kept as advisory intelligence. Rows it previously wrote are removed when you upgrade. +* Added: `define('WEBDECOY_DISABLE', true);` in wp-config.php stops the plugin acting on the front end, so you can always recover over FTP without database access. +* Added: admin notices that say plainly when the plugin is watching rather than acting, and why. + = 2.3.1 - 2026-07-26 = * Fixed: Forwarded detections carry the visitor's own request signature (`cs`). The plugin beacons detections over its own HTTP connection, so the ingest service was identifying visitors by THIS SITE's outbound request headers — identical on every beacon — which collapsed every visitor a site reported into one shared "actor". Header names only, plus Accept-Language and Accept-Encoding; no header values otherwise leave the site, and proxy/CDN-injected names (cf-*, x-forwarded-*) are excluded so a visitor fingerprints consistently wherever they are seen. diff --git a/includes/class-webdecoy-actor-feed.php b/includes/class-webdecoy-actor-feed.php index 02238cc..7047242 100644 --- a/includes/class-webdecoy-actor-feed.php +++ b/includes/class-webdecoy-actor-feed.php @@ -52,6 +52,9 @@ class WebDecoy_Actor_Feed /** Option persisting the delta cursor (`since`) between syncs. */ private const CURSOR_OPTION = 'webdecoy_actor_feed_cursor'; + /** Advisory intel store. Read for scoring; never consulted to block. */ + private const INTEL_OPTION = 'webdecoy_actor_feed_intel'; + /** created_by marker for rows this class owns. */ public const CREATED_BY = 'webdecoy-network'; @@ -184,12 +187,26 @@ public function sync(): void ); } while ($continue); - // Exclude the user's allowlist + invalid IPs (pure pass), then apply the - // authoritative per-IP allowlist gate (covers CIDR) inside apply_blocks. + // Exclude the user's allowlist + invalid IPs (pure pass). $insertable = self::exclude_allowlisted($ip_map, $this->allowlist()); - $this->apply_blocks($insertable); - $this->enforce_cap(); + // The feed is INTELLIGENCE, not enforcement. It used to write these + // addresses straight into wp_webdecoy_blocked_ips. Measured against + // production that was net-negative: only 2 of 4,866 addresses were ever + // seen at more than one site (0.04%), 93% of hostile addresses are gone + // within the hour, 82% of feed entries were already stale by a week, and + // none were still active. Blocking an address the attacker abandoned days + // ago does not stop the attacker — it blocks whoever holds it now, which on + // a residential range is a real person. + // + // The data is kept and surfaced (actor intel, scoring input, rate-limit + // trigger) where being wrong costs a delay rather than a lockout. + // Refs WebDecoy/app#476. + $this->record_intel($insertable); + // The feed no longer owns rows in the block table, so there is nothing left + // to cap there. Any rows it wrote before this version are removed once, on + // upgrade, by purge_feed_blocks(). + self::purge_feed_blocks(); update_option(self::CURSOR_OPTION, $latest_cursor, false); } @@ -235,70 +252,74 @@ private function fetch_page(string $api_key, int $since): array } /** - * Upsert the given IPs as network-owned blocked rows with a rolling expiry. - * Never touches a row owned by anyone else, and never inserts an allowlisted - * IP (exact or CIDR). + * Store the feed as advisory intelligence. Nothing here blocks a request. + * + * Replaces apply_blocks(), which wrote these addresses into the live block + * table. See the call site for why that was removed (WebDecoy/app#476). * * @param array $ip_map */ - private function apply_blocks(array $ip_map): void + private function record_intel(array $ip_map): void { if ($ip_map === []) { + update_option(self::INTEL_OPTION, [], false); return; } - global $wpdb; - $table = $wpdb->prefix . 'webdecoy_blocked_ips'; - $now = current_time('mysql'); - $expires_at = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_HOURS * self::HOUR)); - $blocker = $this->blocker(); - + $intel = []; foreach ($ip_map as $ip => $meta) { - $ip = (string) $ip; - - // Authoritative allowlist gate — covers CIDR ranges the pure filter - // can't. Never override a user's explicit allow. - if ($blocker->is_allowlisted($ip)) { - continue; + $intel[(string) $ip] = [ + 'actor_id' => (string) $meta['actor_id'], + 'last_seen' => (int) $meta['last_seen'], + ]; + if (count($intel) >= self::MAX_NETWORK_ROWS) { + break; } + } - $reason = sprintf( - /* translators: %s: actor id */ - 'WebDecoy network: known attacker (actor %s)', - (string) $meta['actor_id'] - ); - - $existing = $wpdb->get_row( - $wpdb->prepare("SELECT id, created_by FROM {$table} WHERE ip_address = %s", $ip), - ARRAY_A - ); + update_option(self::INTEL_OPTION, $intel, false); + } - if ($existing === null) { - $wpdb->insert($table, [ - 'ip_address' => $ip, - 'reason' => $reason, - 'blocked_at' => $now, - 'expires_at' => $expires_at, - 'created_by' => self::CREATED_BY, - ]); - } elseif (($existing['created_by'] ?? '') === self::CREATED_BY) { - // Refresh our own row: extend expiry and refresh the last-seen - // marker (blocked_at) so eviction ordering stays meaningful. - $wpdb->update( - $table, - [ - 'reason' => $reason, - 'blocked_at' => $now, - 'expires_at' => $expires_at, - ], - ['ip_address' => $ip, 'created_by' => self::CREATED_BY] - ); - } - // else: a human/system block already exists for this IP — leave it - // completely untouched (its reason/expiry are the user's to manage). + /** + * Advisory lookup: has this address been seen attacking another site recently? + * + * Callers must treat a hit as a scoring input or a reason to rate-limit, never + * as grounds to block — see WebDecoy/app#476 for the measurement. + * + * @return array{actor_id:string,last_seen:int}|null + */ + public function intel_for(string $ip): ?array + { + $intel = get_option(self::INTEL_OPTION, []); + if (!is_array($intel) || !isset($intel[$ip]) || !is_array($intel[$ip])) { + return null; + } + return [ + 'actor_id' => (string) ($intel[$ip]['actor_id'] ?? ''), + 'last_seen' => (int) ($intel[$ip]['last_seen'] ?? 0), + ]; + } + /** + * Remove every row this feed previously wrote into the live block table. + * Runs once on upgrade so existing installs stop enforcing on stale addresses. + */ + public static function purge_feed_blocks(): int + { + global $wpdb; + $table = $wpdb->prefix . 'webdecoy_blocked_ips'; + $rows = $wpdb->get_col($wpdb->prepare( + "SELECT ip_address FROM {$table} WHERE created_by = %s", + self::CREATED_BY + )); + $deleted = (int) $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE created_by = %s", + self::CREATED_BY + )); + foreach ((array) $rows as $ip) { wp_cache_delete('webdecoy_blocked_' . $ip, 'webdecoy'); } + return $deleted; } /** diff --git a/includes/class-webdecoy-blocker.php b/includes/class-webdecoy-blocker.php index 2cf4b83..17fcb1d 100644 --- a/includes/class-webdecoy-blocker.php +++ b/includes/class-webdecoy-blocker.php @@ -22,15 +22,24 @@ */ class WebDecoy_Blocker { + /** Widest CIDR an automatic block may write. */ + private const MAX_AUTO_PREFIX_V4 = 24; + private const MAX_AUTO_PREFIX_V6 = 48; + + /** How many recent refusals to keep for the admin. */ + private const REFUSAL_LOG_MAX = 20; + /** * Block an IP address or CIDR range * * @param string $ip IP address or CIDR range to block (supports IPv4 and IPv6) * @param string $reason Reason for blocking * @param int|null $duration_hours Duration in hours, null for permanent + * @param bool $force Skip the safety guards. Only ever true for a block a human + * typed into the admin UI — never for an automatic decision. * @return bool Success */ - public function block(string $ip, string $reason = '', ?int $duration_hours = null): bool + public function block(string $ip, string $reason = '', ?int $duration_hours = null, bool $force = false): bool { global $wpdb; @@ -47,6 +56,14 @@ public function block(string $ip, string $reason = '', ?int $duration_hours = nu if ($bits < 0 || $bits > $maxBits) { return false; } + // An automatic decision must never write a range wide enough to take out a + // whole network. A human typing 10.0.0.0/8 into the admin can still do it. + if (!$force) { + $floor = $version === 4 ? self::MAX_AUTO_PREFIX_V4 : self::MAX_AUTO_PREFIX_V6; + if ($bits < $floor) { + return $this->refuse($ip, $reason, "CIDR wider than /{$floor} from an automatic decision"); + } + } } else { // Validate single IP (IPv4 or IPv6) if (!filter_var($ip, FILTER_VALIDATE_IP)) { @@ -54,6 +71,13 @@ public function block(string $ip, string $reason = '', ?int $duration_hours = nu } } + if (!$force) { + $refusal = $this->guard($ip); + if ($refusal !== null) { + return $this->refuse($ip, $reason, $refusal); + } + } + $table = $wpdb->prefix . 'webdecoy_blocked_ips'; $expires_at = null; @@ -73,7 +97,7 @@ public function block(string $ip, string $reason = '', ?int $duration_hours = nu $table, [ 'reason' => $reason, - 'blocked_at' => current_time('mysql'), + 'blocked_at' => gmdate('Y-m-d H:i:s'), 'expires_at' => $expires_at, ], ['ip_address' => $ip] @@ -85,7 +109,7 @@ public function block(string $ip, string $reason = '', ?int $duration_hours = nu [ 'ip_address' => $ip, 'reason' => $reason, - 'blocked_at' => current_time('mysql'), + 'blocked_at' => gmdate('Y-m-d H:i:s'), 'expires_at' => $expires_at, 'created_by' => is_user_logged_in() ? wp_get_current_user()->user_login : 'system', ] @@ -98,6 +122,109 @@ public function block(string $ip, string $reason = '', ?int $duration_hours = nu return $result !== false; } + /** + * Decide whether an automatic block may write this address. + * + * The failure this exists to prevent: behind a reverse proxy the plugin resolves + * every visitor to the proxy's address, so one hostile request can put the site's + * own front door in the block table and 403 real visitors. See issues #48 and #49. + * + * @param string $ip Single IP or CIDR subnet already validated by the caller. + * @return string|null Refusal reason, or null when the address is safe to block. + */ + private function guard(string $ip): ?string + { + $addr = strpos($ip, '/') !== false ? explode('/', $ip)[0] : $ip; + + // Loopback, private, link-local, and other reserved space. These are never a + // visitor — they are this server, a container gateway, or a LAN peer. + if (!filter_var( + $addr, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + )) { + return 'reserved, private, or loopback address'; + } + + // The address this request actually arrived from, when the plugin has reason to + // believe it is a proxy rather than the visitor. If forwarding headers are + // present but no proxy is trusted, getIP() correctly returns REMOTE_ADDR — which + // is the proxy. Blocking it takes out everyone behind it. + $remote = isset($_SERVER['REMOTE_ADDR']) + ? trim(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) + : ''; + if ($remote !== '' && $addr === $remote && self::forwarding_headers_present()) { + return 'address is this request\'s proxy front door (forwarding headers present, no trusted proxy configured)'; + } + + // Any address inside a configured trusted proxy range is infrastructure by + // definition — we already told the plugin so. + if (function_exists('webdecoy_plugin_trusted_proxies')) { + foreach ((array) webdecoy_plugin_trusted_proxies() as $range) { + if ($this->ip_in_range($addr, $range)) { + return 'address is inside a configured trusted-proxy range'; + } + } + } + + return null; + } + + /** + * True when the request carries a header that means "there is a proxy in front". + */ + public static function forwarding_headers_present(): bool + { + foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP'] as $h) { + if (!empty($_SERVER[$h])) { + return true; + } + } + return false; + } + + /** + * Record a refused block so it is visible rather than silent, and fire a hook. + * + * @return bool Always false — the caller's block did not happen. + */ + private function refuse(string $ip, string $reason, string $refusal): bool + { + $log = get_option('webdecoy_block_refusals', []); + if (!is_array($log)) { + $log = []; + } + array_unshift($log, [ + 'ip' => $ip, + 'reason' => $reason, + 'refusal' => $refusal, + 'at' => gmdate('Y-m-d H:i:s'), + ]); + update_option('webdecoy_block_refusals', array_slice($log, 0, self::REFUSAL_LOG_MAX), false); + + /** + * Fires when an automatic block was refused by the safety guards. + * + * @param string $ip The address that would have been blocked. + * @param string $reason The detection reason the caller supplied. + * @param string $refusal Why the guard refused. + */ + do_action('webdecoy_block_refused', $ip, $reason, $refusal); + + return false; + } + + /** + * Recent refusals, newest first, for the admin UI. + * + * @return array> + */ + public function get_refusals(): array + { + $log = get_option('webdecoy_block_refusals', []); + return is_array($log) ? $log : []; + } + /** * Unblock an IP address * @@ -145,7 +272,7 @@ public function is_blocked(string $ip): bool $exact_blocked = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ip_address = %s AND (expires_at IS NULL OR expires_at > %s)", $ip, - current_time('mysql') + gmdate('Y-m-d H:i:s') )); if ((int) $exact_blocked > 0) { @@ -157,7 +284,7 @@ public function is_blocked(string $ip): bool // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- Searching for CIDR notation IPs containing / $cidr_blocks = $wpdb->get_col($wpdb->prepare( "SELECT ip_address FROM {$table} WHERE ip_address LIKE '%%/%%' AND (expires_at IS NULL OR expires_at > %s)", - current_time('mysql') + gmdate('Y-m-d H:i:s') )); foreach ($cidr_blocks as $cidr) { @@ -195,7 +322,7 @@ public function get_blocked_ips(array $args = []): array $where = '1=1'; if (!$args['include_expired']) { - $where .= $wpdb->prepare(" AND (expires_at IS NULL OR expires_at > %s)", current_time('mysql')); + $where .= $wpdb->prepare(" AND (expires_at IS NULL OR expires_at > %s)", gmdate('Y-m-d H:i:s')); } $orderby = in_array($args['orderby'], ['ip_address', 'blocked_at', 'expires_at']) ? $args['orderby'] : 'blocked_at'; @@ -226,7 +353,7 @@ public function get_blocked_count(bool $include_expired = false): int $where = '1=1'; if (!$include_expired) { - $where .= $wpdb->prepare(" AND (expires_at IS NULL OR expires_at > %s)", current_time('mysql')); + $where .= $wpdb->prepare(" AND (expires_at IS NULL OR expires_at > %s)", gmdate('Y-m-d H:i:s')); } return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}"); @@ -247,7 +374,7 @@ public function get_block_info(string $ip): ?array $result = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE ip_address = %s AND (expires_at IS NULL OR expires_at > %s)", $ip, - current_time('mysql') + gmdate('Y-m-d H:i:s') ), ARRAY_A); return $result ?: null; @@ -321,7 +448,7 @@ public function cleanup_expired(): int return $wpdb->query($wpdb->prepare( "DELETE FROM {$table} WHERE expires_at IS NOT NULL AND expires_at < %s", - current_time('mysql') + gmdate('Y-m-d H:i:s') )); } @@ -592,7 +719,7 @@ public function get_stats(): array $total = $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); $active = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE expires_at IS NULL OR expires_at > %s", - current_time('mysql') + gmdate('Y-m-d H:i:s') )); $expired = $total - $active; $permanent = $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE expires_at IS NULL"); diff --git a/includes/class-webdecoy-woocommerce.php b/includes/class-webdecoy-woocommerce.php index e3b7f70..34ddf48 100644 --- a/includes/class-webdecoy-woocommerce.php +++ b/includes/class-webdecoy-woocommerce.php @@ -67,14 +67,14 @@ public function check_checkout(): void return; } + // The checkout path stops the checkout and records the detection. It must + // never write a sitewide IP block: behind an unconfigured proxy every + // shopper shares one address, so a single false positive at checkout would + // 403 the whole store on every page. Refusing this order is already the + // proportionate response. Refs WebDecoy/wordpress-plugin#52. + // Check velocity if (!$this->check_velocity($ip)) { - $this->blocker->block( - $ip, - 'Checkout velocity exceeded', - $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null - ); - wc_add_notice( __('Too many checkout attempts. Please try again later.', 'webdecoy'), 'error' @@ -86,12 +86,6 @@ public function check_checkout(): void // Check for card testing patterns if ($this->detect_card_testing($ip)) { - $this->blocker->block( - $ip, - 'Card testing detected', - $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null - ); - wc_add_notice( __('Suspicious checkout activity detected.', 'webdecoy'), 'error' @@ -106,12 +100,6 @@ public function check_checkout(): void $result = $detector->analyze(); if ($result->shouldBlock($this->options['min_score_to_block'])) { - $this->blocker->block( - $ip, - 'Bot detected at checkout: score ' . $result->getScore(), - $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null - ); - wc_add_notice( __('Your checkout has been blocked due to suspicious activity.', 'webdecoy'), 'error' @@ -132,7 +120,9 @@ private function check_velocity(string $ip): bool $limit = $this->options['checkout_velocity_limit'] ?? 5; $window = $this->options['checkout_velocity_window'] ?? 3600; - $attempts = $this->get_recent_attempts($ip, $window); + // A completed order is not a failed checkout attempt. Counting successes + // here throttled legitimate repeat buyers. Refs #52. + $attempts = $this->get_recent_attempts($ip, $window, false); return count($attempts) < $limit; } @@ -145,7 +135,12 @@ private function check_velocity(string $ip): bool */ private function detect_card_testing(string $ip): bool { - $attempts = $this->get_recent_attempts($ip, 3600); + // Successful orders are not evidence of card testing. track_payment() only + // flips a row's status to 'success' and never removes it, so without this + // filter three completed sub-$5 orders from one address in an hour — the + // normal profile for digital downloads, donations, tips and add-ons — read + // as an attack. Refs WebDecoy/wordpress-plugin#52. + $attempts = $this->get_recent_attempts($ip, 3600, false); if (count($attempts) < 2) { return false; @@ -282,15 +277,23 @@ public function track_failure(int $order_id, string $reason = 'failed'): void * @param int $window Time window in seconds * @return array */ - private function get_recent_attempts(string $ip, int $window): array + private function get_recent_attempts(string $ip, int $window, bool $include_successful = true): array { global $wpdb; $table = $wpdb->prefix . 'webdecoy_checkout_attempts'; $since = gmdate('Y-m-d H:i:s', strtotime("-{$window} seconds")); + if ($include_successful) { + return $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$table} WHERE ip_address = %s AND created_at > %s ORDER BY created_at DESC", + $ip, + $since + ), ARRAY_A) ?: []; + } + return $wpdb->get_results($wpdb->prepare( - "SELECT * FROM {$table} WHERE ip_address = %s AND created_at > %s ORDER BY created_at DESC", + "SELECT * FROM {$table} WHERE ip_address = %s AND created_at > %s AND status <> 'success' ORDER BY created_at DESC", $ip, $since ), ARRAY_A) ?: []; diff --git a/readme.txt b/readme.txt index a656764..1b36a2f 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Donate link: https://webdecoy.com Tags: security, bot detection, spam protection, woocommerce, firewall Requires at least: 6.1 Tested up to: 7.0 -Stable tag: 2.3.1 +Stable tag: 2.3.2 Requires PHP: 7.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -195,6 +195,20 @@ The bundled good-bot list (sdk/src/GoodBotList.php) stores a documentation URL f == Changelog == += 2.3.2 = +Safety release — please update. This version deliberately makes the plugin do less by default. + +* **Monitor mode is now the default.** WebDecoy detects, logs and reports everything and blocks nothing until you switch blocking on under Blocking → Monitor mode. Existing sites are moved to monitor mode by this update. +* Fixed: behind Cloudflare, a load balancer or most managed hosts, every visitor resolved to the proxy's address, so one hostile request could block real visitors for 24 hours. The plugin now detects that condition, refuses to block while it holds, and tells you in the admin. +* Fixed: automatic blocks can no longer target loopback, private ranges, a trusted proxy, or your own CDN front door, and can no longer write a range wider than /24 (IPv4) or /48 (IPv6). +* Fixed: block expiry was stored in UTC and compared against site-local time, so on sites at UTC+1 or further east a short block expired the instant it was written. +* Fixed: a honeypot hit blocked the address permanently instead of for the configured duration. +* Fixed (WooCommerce): three successful sub-$5 orders in an hour were classified as card testing. Completed orders no longer count toward card-testing patterns or the checkout velocity limit. +* Changed (WooCommerce): a suspicious checkout is refused and recorded; it no longer blocks the address across your whole site. +* Changed: default block duration is 1 hour, was 24. +* Changed: the cross-site actor feed is now advisory intelligence and no longer writes to your block list. Rows it previously wrote are removed on upgrade. +* Added: `define('WEBDECOY_DISABLE', true);` in wp-config.php as an emergency off switch. + = 2.3.1 = * Fixed: Detections forwarded from your site are now identified by the visitor's own request signature. Previously they were identified by your server's outgoing connection, which is the same for every visitor — so every visitor a site reported was grouped into a single "actor" in the dashboard. Only request header NAMES plus Accept-Language and Accept-Encoding are sent; no header values leave your site. diff --git a/webdecoy.php b/webdecoy.php index 2595593..66627ed 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -3,7 +3,7 @@ * Plugin Name: WebDecoy Bot Detection * Plugin URI: https://webdecoy.com/wordpress * Description: Protect your WordPress site from bots, spam, and carding attacks with WebDecoy's advanced threat detection. - * Version: 2.3.1 + * Version: 2.3.2 * Requires at least: 6.1 * Requires PHP: 7.4 * Author: WebDecoy @@ -41,7 +41,7 @@ function str_starts_with(string $haystack, string $needle): bool } // Plugin constants -define('WEBDECOY_VERSION', '2.3.1'); +define('WEBDECOY_VERSION', '2.3.2'); define('WEBDECOY_PLUGIN_FILE', __FILE__); define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__)); @@ -225,9 +225,16 @@ private function load_options(): void 'custom_allowlist' => [], // Blocking Settings + // Monitor mode is the safe default: everything is detected, logged and + // reported, and nothing is ever blocked, throttled or 403'd. Turn it off + // deliberately, once the Detections page shows what enforcement would do. + 'monitor_mode' => true, 'ip_allowlist' => [], // IPs/CIDRs that bypass all detection 'block_action' => 'block', - 'block_duration' => 24, + // 93% of hostile addresses are gone inside an hour, so a longer default + // buys almost nothing against the adversary and carries a full extra day + // of exposure to blocking whoever inherits the address next. + 'block_duration' => 1, 'show_block_page' => true, 'block_page_message' => 'Access to this site has been restricted.', @@ -354,6 +361,161 @@ public function get_trusted_proxies(): array return array_values(array_filter(array_map('trim', $proxies))); } + /** + * True when the request arrived through a proxy the plugin has not been told + * about. In that state SignalCollector::getIP() correctly refuses the spoofable + * forwarding headers and returns REMOTE_ADDR — which is the proxy, not the + * visitor — so every visitor resolves to the same address and any IP-keyed + * action hits all of them at once. + * + * This is a detection of OUR misconfiguration. It must never cause the plugin to + * start trusting the headers: that would let any client choose their own identity + * and frame a third party into the block table. + */ + public function proxy_misconfigured(): bool + { + return WebDecoy_Blocker::forwarding_headers_present() + && $this->get_trusted_proxies() === []; + } + + /** + * Why enforcement is currently suppressed, or '' when it is live. + * + * Detection, logging and cloud reporting continue in every suppressed state — + * only the act of blocking, throttling or serving a 403 is withheld. + */ + public function suppression_reason(): string + { + if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { + return 'disabled'; + } + if (!empty($this->options['monitor_mode'])) { + return 'monitor'; + } + if ($this->proxy_misconfigured()) { + return 'proxy'; + } + return ''; + } + + /** Convenience wrapper: is any enforcement action allowed on this request? */ + public function enforcement_suppressed(): bool + { + return $this->suppression_reason() !== ''; + } + + /** + * One-time migrations, keyed on the stored schema version. + */ + public function maybe_upgrade(): void + { + $stored = get_option('webdecoy_schema_version', '0'); + if (version_compare($stored, '2.3.2', '>=')) { + return; + } + + // 2.3.2: the cross-site actor feed no longer writes to the block table. + // Remove every row it wrote, so upgrading actually stops the enforcement + // rather than leaving up to 2,000 stale addresses blocked until they age + // out. Refs WebDecoy/app#476. + if (class_exists('WebDecoy_Actor_Feed')) { + $purged = WebDecoy_Actor_Feed::purge_feed_blocks(); + if ($purged > 0) { + set_transient('webdecoy_upgrade_notice_232', $purged, DAY_IN_SECONDS); + } + } + + // 2.3.2: bring sites still sitting on the old 24-hour default down to the + // new 1-hour default. A site that deliberately chose some other number keeps + // it; only the untouched default moves. 24 is indistinguishable from a + // deliberate 24, and on a safety release the shorter block is the safer of + // the two mistakes. + $saved = get_option('webdecoy_options', []); + if (is_array($saved) && isset($saved['block_duration']) && (int) $saved['block_duration'] === 24) { + $saved['block_duration'] = 1; + $this->update_options_raw($saved); + $this->load_options(); + } + + update_option('webdecoy_schema_version', '2.3.2', true); + } + + /** + * Tell the administrator, on every admin screen, when the plugin is watching + * rather than acting — and why. A security plugin that has silently stopped + * enforcing is worse than one that never did. + */ + public function render_state_notices(): void + { + if (!current_user_can('manage_options')) { + return; + } + + $settings_url = admin_url('admin.php?page=webdecoy-settings'); + + if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { + printf( + '

%s %s

', + esc_html__('WebDecoy is disabled.', 'webdecoy'), + esc_html__('WEBDECOY_DISABLE is defined in wp-config.php, so nothing is detected or blocked on the front end. Remove it to resume.', 'webdecoy') + ); + return; + } + + // The dangerous state: a proxy in front, and we were never told about it, + // so every visitor resolves to the same address. + if ($this->proxy_misconfigured()) { + $headers = []; + foreach (['HTTP_CF_CONNECTING_IP' => 'CF-Connecting-IP', 'HTTP_X_FORWARDED_FOR' => 'X-Forwarded-For', 'HTTP_X_REAL_IP' => 'X-Real-IP'] as $key => $label) { + if (!empty($_SERVER[$key])) { + $headers[] = $label; + } + } + // Escape each header name individually, THEN join with markup. Escaping + // the joined string would turn the separator's tags into literal text. + $header_html = '' . implode(', ', array_map('esc_html', $headers)) . ''; + printf( + '

%s %s

%s

%s

', + esc_html__('WebDecoy is not blocking: this site is behind a proxy it has not been told about.', 'webdecoy'), + wp_kses( + sprintf( + /* translators: %s: comma-separated list of HTTP header names */ + esc_html__('Your requests arrive with %s, but no trusted proxy is configured. Until that is fixed every visitor looks like the same address, so blocking anyone would block everyone.', 'webdecoy'), + $header_html + ), + ['code' => []] + ), + esc_html__('Detection, logging and reporting continue as normal. Only blocking, rate limiting and the 403 page are withheld.', 'webdecoy'), + esc_url($settings_url), + esc_html__('Configure trusted proxies', 'webdecoy') + ); + return; + } + + if (!empty($this->options['monitor_mode'])) { + $stats = get_option('webdecoy_suppressed_actions', []); + $total = 0; + if (is_array($stats)) { + foreach ($stats as $day) { + $total += (int) ($day['count'] ?? 0); + } + } + printf( + '

%s %s

%s

', + esc_html__('WebDecoy is in monitor mode.', 'webdecoy'), + $total > 0 + ? sprintf( + /* translators: %d: number of actions that were withheld */ + esc_html__('Everything is detected and logged; nothing is blocked. Enforcing would have acted on %d requests in the last 30 days.', 'webdecoy'), + (int) $total + ) + : esc_html__('Everything is detected and logged; nothing is blocked. No request has met the bar for enforcement yet.', 'webdecoy'), + esc_url($settings_url), + esc_html__('Review and turn on blocking', 'webdecoy') + ); + } + } + /** * Sanitize the trusted-proxies setting: accept newline/comma separated IPs or * CIDR ranges, discard anything that isn't a valid IP or CIDR, and store back @@ -647,6 +809,8 @@ private function init_hooks(): void if (is_admin()) { add_action('admin_menu', [$this, 'admin_menu']); add_action('admin_init', [$this, 'register_settings']); + add_action('admin_init', [$this, 'maybe_upgrade']); + add_action('admin_notices', [$this, 'render_state_notices']); add_action('wp_dashboard_setup', [$this, 'dashboard_widget']); add_action('admin_enqueue_scripts', [$this, 'admin_scripts']); } @@ -854,6 +1018,13 @@ public function deactivate(): void */ public function early_check(): void { + // Emergency off switch. `define('WEBDECOY_DISABLE', true);` in wp-config.php + // stops the plugin doing anything on the front end, so an owner who has locked + // themselves out can recover over FTP without touching the database. + if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { + return; + } + // Skip if disabled if (!$this->options['enabled']) { return; @@ -873,7 +1044,8 @@ public function early_check(): void return; } - if ($blocker->is_blocked($ip)) { + // Detection continues in every suppressed state; only the 403 is withheld. + if ($blocker->is_blocked($ip) && !$this->enforcement_suppressed()) { $this->block_request(__('Your IP has been blocked.', 'webdecoy')); return; } @@ -1225,8 +1397,99 @@ private function collect_request_headers(): array * @param \WebDecoy\Rules\RuleEngineResult $result * @param string $ip */ + /** + * The configured block duration in hours, or null for permanent. + */ + private function block_duration(): ?int + { + $hours = (int) ($this->options['block_duration'] ?? 1); + return $hours > 0 ? $hours : null; + } + + /** + * The single gate every automatic block passes through. + * + * Returns false — and blocks nothing — whenever enforcement is suppressed, so the + * caller can skip serving a 403 too. Manual blocks from the admin UI deliberately + * do NOT come through here: a human typing an address has already decided. + * + * @return bool True when the block was actually written. + */ + private function enforce_block(string $ip, string $reason, ?int $duration = null): bool + { + $suppression = $this->suppression_reason(); + if ($suppression !== '') { + do_action('webdecoy_enforcement_suppressed', $ip, $suppression, null); + $this->record_suppressed_action($suppression, $reason); + return false; + } + + $blocker = new WebDecoy_Blocker(); + return $blocker->block($ip, $reason, $duration ?? $this->block_duration()); + } + + /** + * Count what enforcement would have done, so monitor mode produces a number + * rather than silence. Kept as a bounded option — no new table, no per-request + * write beyond a single autoloaded counter. + */ + private function record_suppressed_action(string $suppression, string $reason): void + { + $stats = get_option('webdecoy_suppressed_actions', []); + if (!is_array($stats)) { + $stats = []; + } + $day = gmdate('Y-m-d'); + if (!isset($stats[$day])) { + $stats[$day] = ['count' => 0, 'by' => []]; + } + $stats[$day]['count']++; + $key = substr($reason, 0, 80); + $stats[$day]['by'][$key] = ($stats[$day]['by'][$key] ?? 0) + 1; + if (count($stats[$day]['by']) > 25) { + arsort($stats[$day]['by']); + $stats[$day]['by'] = array_slice($stats[$day]['by'], 0, 25, true); + } + // Keep 30 days. + if (count($stats) > 30) { + ksort($stats); + $stats = array_slice($stats, -30, null, true); + } + update_option('webdecoy_suppressed_actions', $stats, false); + unset($suppression); + } + private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, string $ip): void { + // Monitor mode, the kill switch, or an unconfigured proxy: the violation has + // already been logged and reported by the caller. Take no action on the + // request itself. This is the single gate for every enforcement path below — + // 429, deceptive response, local IP block and 403 alike. + $suppression = $this->suppression_reason(); + if ($suppression !== '') { + /** + * Fires when enforcement was withheld for a request that would otherwise + * have been acted on. The counterfactual, for the Detections page. + * + * @param string $ip + * @param string $suppression One of 'disabled', 'monitor', 'proxy'. + * @param \WebDecoy\Rules\RuleEngineResult $result + */ + do_action('webdecoy_enforcement_suppressed', $ip, $suppression, $result); + // Deliberately NOT counted for THROTTLE. Rate limiting is high-volume by + // nature, it previously performed no database write on this path, and one + // option write per throttled request would turn a bot flood into an + // options-table flood — on every install, since monitor mode is the + // default. The rate limiter's own counters already carry that number. + if ($result->action !== \WebDecoy\Rules\RuleResult::THROTTLE) { + $this->record_suppressed_action( + $suppression, + $result->reason ?? ('Rule enforced: ' . ($result->rule ?? 'rule')) + ); + } + return; + } + if ($result->action === \WebDecoy\Rules\RuleResult::THROTTLE) { $meta = is_array($result->metadata) ? $result->metadata : []; $retryAfter = isset($meta['retryAfter']) ? max(1, intval($meta['retryAfter'])) : 60; @@ -1391,10 +1654,9 @@ private function handle_blocking(\WebDecoy\DetectionResult $result, string $ip): } // Default action: block - $blocker = new WebDecoy_Blocker(); - $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; - $blocker->block($ip, 'Bot detection score: ' . $result->getScore(), $duration); - $this->block_request($this->options['block_page_message']); + if ($this->enforce_block($ip, 'Bot detection score: ' . $result->getScore())) { + $this->block_request($this->options['block_page_message']); + } } /** @@ -1599,9 +1861,7 @@ public function check_canary_login($user, string $username, string $password) // Confirmed decoy exfiltration is always CRITICAL — surface the moment. $this->flag_critical_moment($ip, \WebDecoy\DetectionResult::THREAT_CRITICAL); - $blocker = new WebDecoy_Blocker(); - $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; - $blocker->block($ip, 'Canary credential use (decoy exfiltration)', $duration); + $this->enforce_block($ip, 'Canary credential use (decoy exfiltration)'); return new \WP_Error( 'webdecoy_canary', @@ -1720,10 +1980,10 @@ private function check_honeypot(string $context): void if (isset($_POST[$honeypot_name]) && !empty($_POST[$honeypot_name])) { // Honeypot triggered - definitely a bot $ip = $this->get_client_ip(); - $blocker = new WebDecoy_Blocker(); - $blocker->block($ip, 'Honeypot triggered: ' . $context); - - $this->block_request(__('Suspicious activity detected.', 'webdecoy')); + // Previously passed no duration at all, which meant a permanent block. + if ($this->enforce_block($ip, 'Honeypot triggered: ' . $context)) { + $this->block_request(__('Suspicious activity detected.', 'webdecoy')); + } } } @@ -1878,8 +2138,9 @@ public function sanitize_options(array $input): array // stored as an array of valid entries. $allowlist = $this->sanitize_trusted_proxies($input['ip_allowlist'] ?? ''); $sanitized['ip_allowlist'] = $allowlist === '' ? [] : explode("\n", $allowlist); + $sanitized['monitor_mode'] = !empty($input['monitor_mode']); $sanitized['block_action'] = in_array($input['block_action'] ?? 'block', ['block', 'challenge', 'log']) ? $input['block_action'] : 'block'; - $sanitized['block_duration'] = max(0, intval($input['block_duration'] ?? 24)); + $sanitized['block_duration'] = max(0, intval($input['block_duration'] ?? 1)); $sanitized['show_block_page'] = !empty($input['show_block_page']); $sanitized['block_page_message'] = sanitize_textarea_field($input['block_page_message'] ?? ''); @@ -2218,10 +2479,8 @@ public function ajax_client_detection(): void if ($score >= $this->options['min_score_to_block'] && $action === 'block') { // Only block if action is set to 'block' - $blocker = new WebDecoy_Blocker(); - $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; $flags = implode(', ', $detection['f'] ?? []); - $blocker->block($ip, "Client detection (score: {$score}): {$flags}", $duration); + $this->enforce_block($ip, "Client detection (score: {$score}): {$flags}"); } // Forward to WebDecoy ingest service if premium @@ -2495,8 +2754,9 @@ public function ajax_block_ip(): void return; } + // force: a human typed this address, so the safety guards do not apply. $blocker = new WebDecoy_Blocker(); - $blocker->block($ip, $reason, $duration > 0 ? $duration : null); + $blocker->block($ip, $reason, $duration > 0 ? $duration : null, true); wp_send_json_success(['message' => __('IP blocked successfully.', 'webdecoy')]); } @@ -2547,21 +2807,34 @@ public function ajax_bulk_block(): void $blocker = new WebDecoy_Blocker(); $blocked = 0; + $refused = 0; + // Not forced: these addresses came from detection rows, not from a human + // typing them, so behind an unconfigured proxy they may all be the front + // door. The guards apply and refusals are reported back. foreach ($ips as $ip) { if (filter_var($ip, FILTER_VALIDATE_IP)) { - $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; - $blocker->block($ip, 'Bulk block from detections page', $duration); - $blocked++; + if ($blocker->block($ip, 'Bulk block from detections page', $this->block_duration())) { + $blocked++; + } else { + $refused++; + } } } wp_send_json_success([ - 'message' => sprintf( - /* translators: %d: number of IPs blocked */ - __('%d IPs blocked.', 'webdecoy'), - $blocked - ), + 'message' => $refused > 0 + ? sprintf( + /* translators: 1: number of IPs blocked, 2: number refused */ + __('%1$d IPs blocked. %2$d refused as infrastructure addresses — check that your trusted proxy settings match how this site is served.', 'webdecoy'), + $blocked, + $refused + ) + : sprintf( + /* translators: %d: number of IPs blocked */ + __('%d IPs blocked.', 'webdecoy'), + $blocked + ), ]); } @@ -2965,5 +3238,16 @@ function webdecoy(): WebDecoy_Plugin return WebDecoy_Plugin::instance(); } +/** + * Trusted-proxy ranges, reachable from classes that do not hold a plugin reference. + * Used by WebDecoy_Blocker::guard() to refuse blocking infrastructure addresses. + * + * @return string[] + */ +function webdecoy_plugin_trusted_proxies(): array +{ + return WebDecoy_Plugin::instance()->get_trusted_proxies(); +} + // Initialize plugin webdecoy(); From e10098fc5602feb59313e7540332af4c5abbd8ae Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Wed, 29 Jul 2026 17:13:52 -0500 Subject: [PATCH 2/3] fix: close a complete enforcement bypass introduced by the proxy guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cdn-files/plugin-info.json | 4 +- includes/class-webdecoy-blocker.php | 50 +++++++++++++++----- includes/class-webdecoy-woocommerce.php | 61 +++++++++++++++++++------ webdecoy.php | 51 +++++++++++++++++---- 4 files changed, 128 insertions(+), 38 deletions(-) diff --git a/cdn-files/plugin-info.json b/cdn-files/plugin-info.json index ba2d62e..8354c48 100644 --- a/cdn-files/plugin-info.json +++ b/cdn-files/plugin-info.json @@ -1,13 +1,13 @@ { "name": "WebDecoy Bot Detection", "slug": "webdecoy", - "version": "2.3.1", + "version": "2.3.2", "author": "WebDecoy", "author_profile": "https://webdecoy.com", "requires": "6.1", "tested": "7.0", "requires_php": "7.4", - "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.3.1.zip", + "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.3.2.zip", "sections": { "description": "

WebDecoy provides enterprise-grade bot detection and fraud protection for WordPress websites. Unlike simple CAPTCHA solutions, WebDecoy uses a layered defense approach that analyzes visitors from multiple angles — including deterministic tripwires that catch scanners with zero false positives.

Key Features

  • Deterministic tripwires (hidden honeypot paths) — zero-false-positive bot blocking
  • Server-side and client-side bot detection
  • Invisible proof-of-work challenge (no external CAPTCHA service)
  • Comment, login, and registration spam protection
  • WooCommerce carding attack prevention
  • 60+ good bots automatically allowed
  • AI crawler detection and blocking
  • Optional WebDecoy Cloud: centralized dashboard, rotation-proof device lockouts, and WAF integrations (push confirmed attackers to Cloudflare or AWS WAF — blocked before they reach WordPress)
", "installation": "
  1. Upload the plugin files to /wp-content/plugins/webdecoy
  2. Activate the plugin through the Plugins menu
  3. Tripwires and local protection are active out of the box — no API key required
  4. Optionally go to WebDecoy > Settings > WebDecoy Cloud to connect for centralized monitoring and enforcement
", diff --git a/includes/class-webdecoy-blocker.php b/includes/class-webdecoy-blocker.php index 17fcb1d..9803d46 100644 --- a/includes/class-webdecoy-blocker.php +++ b/includes/class-webdecoy-blocker.php @@ -146,15 +146,18 @@ private function guard(string $ip): ?string return 'reserved, private, or loopback address'; } - // The address this request actually arrived from, when the plugin has reason to - // believe it is a proxy rather than the visitor. If forwarding headers are - // present but no proxy is trusted, getIP() correctly returns REMOTE_ADDR — which - // is the proxy. Blocking it takes out everyone behind it. + // The address this request arrived from, when the site is known to sit behind + // an unconfigured proxy. In that state getIP() correctly returns REMOTE_ADDR — + // which is the proxy — so blocking it takes out everyone behind it. + // + // The "behind a proxy" signal is the stored flag, NOT this request's headers: + // keying off the headers would let an attacker who reaches the origin directly + // send X-Forwarded-For and make guard() refuse to block them. $remote = isset($_SERVER['REMOTE_ADDR']) ? trim(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) : ''; - if ($remote !== '' && $addr === $remote && self::forwarding_headers_present()) { - return 'address is this request\'s proxy front door (forwarding headers present, no trusted proxy configured)'; + if ($remote !== '' && $addr === $remote && get_option('webdecoy_proxy_detected', false)) { + return 'address is this site\'s proxy front door (proxy detected, no trusted proxy configured)'; } // Any address inside a configured trusted proxy range is infrastructure by @@ -171,16 +174,39 @@ private function guard(string $ip): ?string } /** - * True when the request carries a header that means "there is a proxy in front". + * Headers that mean "a proxy resolved the client for us". Deliberately broad: + * this list only ever drives an ADMIN-side detection (see + * WebDecoy_Plugin::maybe_flag_proxy()), so a false positive costs a notice while + * a false negative costs the safety backstop entirely. */ - public static function forwarding_headers_present(): bool + private const FORWARDING_HEADERS = [ + 'HTTP_CF_CONNECTING_IP' => 'CF-Connecting-IP', + 'HTTP_TRUE_CLIENT_IP' => 'True-Client-IP', + 'HTTP_X_FORWARDED_FOR' => 'X-Forwarded-For', + 'HTTP_X_REAL_IP' => 'X-Real-IP', + 'HTTP_FORWARDED' => 'Forwarded', + 'HTTP_X_SUCURI_CLIENTIP' => 'X-Sucuri-ClientIP', + 'HTTP_FASTLY_CLIENT_IP' => 'Fastly-Client-IP', + 'HTTP_INCAP_CLIENT_IP' => 'Incap-Client-IP', + 'HTTP_X_CLUSTER_CLIENT_IP' => 'X-Cluster-Client-IP', + ]; + + /** + * The human-readable name of the first forwarding header on this request, or ''. + * + * DO NOT call this to decide enforcement behaviour on a front-end request — the + * headers are client-controlled, so that would be a bypass. It exists for the + * admin-side proxy detection only. + */ + public static function forwarding_header_seen(): string { - foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP'] as $h) { - if (!empty($_SERVER[$h])) { - return true; + foreach (self::FORWARDING_HEADERS as $key => $label) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- presence check only; the value is never used + if (!empty($_SERVER[$key])) { + return $label; } } - return false; + return ''; } /** diff --git a/includes/class-webdecoy-woocommerce.php b/includes/class-webdecoy-woocommerce.php index 34ddf48..c3709c5 100644 --- a/includes/class-webdecoy-woocommerce.php +++ b/includes/class-webdecoy-woocommerce.php @@ -58,8 +58,11 @@ public function check_checkout(): void $ip = $this->get_client_ip(); - // Check if already blocked - if ($this->blocker->is_blocked($ip)) { + // Check if already blocked. Monitor mode, WEBDECOY_DISABLE and an + // unconfigured proxy all mean "watch, don't act" — and that has to hold on + // the checkout path too, or the admin notice promising nothing is blocked + // is a lie to a merchant losing orders. + if ($this->blocker->is_blocked($ip) && !$this->suppressed()) { wc_add_notice( __('Your checkout has been blocked due to suspicious activity.', 'webdecoy'), 'error' @@ -436,6 +439,37 @@ public function check_velocity_public(string $ip): bool return $this->check_velocity($ip); } + /** + * True when the plugin is in a watch-only state — monitor mode, WEBDECOY_DISABLE, + * or an unconfigured reverse proxy. Public so the Store API closure can consult it. + * + * Fails CLOSED to "not suppressed" only if the main plugin class is unavailable, + * which cannot happen while this class is loaded by it. + */ + public function suppressed(): bool + { + if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { + return true; + } + if (function_exists('webdecoy')) { + return webdecoy()->enforcement_suppressed(); + } + return !empty($this->options['monitor_mode']); + } + + /** + * Record a detection - public accessor for the Store API / Blocks integration, + * which refuses the order via RouteException and must still leave a record. + * + * @param string $ip + * @param string $reason + * @param int|null $score + */ + public function log_detection_public(string $ip, string $reason, ?int $score = null): void + { + $this->log_detection($ip, $reason, $score); + } + /** * Detect card testing - public accessor for Blocks integration * @@ -669,9 +703,9 @@ function ($order, $request) { $woo = new WebDecoy_WooCommerce($options); $ip = $woo->get_client_ip_public(); - // Check if already blocked + // Check if already blocked — suppressed states apply here too. $blocker = new WebDecoy_Blocker(); - if ($blocker->is_blocked($ip)) { + if ($blocker->is_blocked($ip) && !$woo->suppressed()) { throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_blocked', esc_html(__('Your checkout has been blocked due to suspicious activity.', 'webdecoy')), @@ -679,13 +713,16 @@ function ($order, $request) { ); } + // As on the classic checkout path, refusing the order IS the response. + // The RouteException below already stops it with a 429/403. Writing a + // sitewide IP block from here would, behind an unconfigured proxy where + // every shopper shares one address, 403 the whole store on every page + // from a single checkout false positive. + // Refs WebDecoy/wordpress-plugin#52. + // Check velocity if (!$woo->check_velocity_public($ip)) { - $blocker->block( - $ip, - 'Checkout velocity exceeded (Blocks)', - $options['block_duration'] > 0 ? $options['block_duration'] : null - ); + $woo->log_detection_public($ip, 'velocity_exceeded'); throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_velocity', @@ -696,11 +733,7 @@ function ($order, $request) { // Check for card testing patterns if ($woo->detect_card_testing_public($ip)) { - $blocker->block( - $ip, - 'Card testing detected (Blocks)', - $options['block_duration'] > 0 ? $options['block_duration'] : null - ); + $woo->log_detection_public($ip, 'card_testing'); throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_carding', diff --git a/webdecoy.php b/webdecoy.php index 66627ed..21fdb5a 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -374,8 +374,39 @@ public function get_trusted_proxies(): array */ public function proxy_misconfigured(): bool { - return WebDecoy_Blocker::forwarding_headers_present() - && $this->get_trusted_proxies() === []; + // NEVER infer this from the CURRENT request's headers. Forwarding headers are + // client-controlled, so `forwarding_headers_present() && no trusted proxy` + // would let any visitor send `X-Forwarded-For:` and switch the whole + // enforcement stack off for their own request — a complete bypass, and worse + // than the bug this state exists to contain. + // + // The flag is written only from an authenticated administrator's request + // (maybe_flag_proxy(), on admin_init), which is a trustworthy sample of how + // traffic actually reaches this site and cannot be forged by a visitor. + return $this->get_trusted_proxies() === [] + && (bool) get_option('webdecoy_proxy_detected', false); + } + + /** + * Record whether this site sits behind an unconfigured reverse proxy, sampled + * from an administrator's own request. Front-end code reads the stored flag; it + * must never read the headers directly. See proxy_misconfigured(). + */ + public function maybe_flag_proxy(): void + { + if (!current_user_can('manage_options')) { + return; + } + + $seen = WebDecoy_Blocker::forwarding_header_seen(); + $flagged = (bool) get_option('webdecoy_proxy_detected', false); + + if ($seen !== '' && !$flagged) { + update_option('webdecoy_proxy_detected', $seen, false); + } elseif ($seen === '' && $flagged) { + // The proxy is gone, or the admin is reaching the origin directly. + delete_option('webdecoy_proxy_detected'); + } } /** @@ -465,15 +496,12 @@ public function render_state_notices(): void // The dangerous state: a proxy in front, and we were never told about it, // so every visitor resolves to the same address. if ($this->proxy_misconfigured()) { - $headers = []; - foreach (['HTTP_CF_CONNECTING_IP' => 'CF-Connecting-IP', 'HTTP_X_FORWARDED_FOR' => 'X-Forwarded-For', 'HTTP_X_REAL_IP' => 'X-Real-IP'] as $key => $label) { - if (!empty($_SERVER[$key])) { - $headers[] = $label; - } + // The stored flag holds the header name that was seen on an admin request. + $seen = (string) get_option('webdecoy_proxy_detected', ''); + if ($seen === '' || $seen === '1') { + $seen = __('a forwarding header', 'webdecoy'); } - // Escape each header name individually, THEN join with markup. Escaping - // the joined string would turn the separator's tags into literal text. - $header_html = '' . implode(', ', array_map('esc_html', $headers)) . ''; + $header_html = '' . esc_html($seen) . ''; printf( '

%s %s

%s

%s

', esc_html__('WebDecoy is not blocking: this site is behind a proxy it has not been told about.', 'webdecoy'), @@ -810,6 +838,9 @@ private function init_hooks(): void add_action('admin_menu', [$this, 'admin_menu']); add_action('admin_init', [$this, 'register_settings']); add_action('admin_init', [$this, 'maybe_upgrade']); + // Sample "are we behind a proxy" from a trusted (admin) request, so the + // front end never has to consult a client-controlled header. + add_action('admin_init', [$this, 'maybe_flag_proxy']); add_action('admin_notices', [$this, 'render_state_notices']); add_action('wp_dashboard_setup', [$this, 'dashboard_widget']); add_action('admin_enqueue_scripts', [$this, 'admin_scripts']); From f2e2a6a9a8231651a2ad0a3fb7c6c8bef188f351 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Wed, 29 Jul 2026 17:25:20 -0500 Subject: [PATCH 3/3] fix: consult the suppression gate at every enforcement point (#56 #57 #58 #59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- admin/partials/settings-page.php | 2 +- includes/class-webdecoy-activator.php | 6 +- includes/class-webdecoy-woocommerce.php | 84 +++++++++++++++---------- webdecoy.php | 70 +++++++++++++++++++-- 4 files changed, 122 insertions(+), 40 deletions(-) diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index a3a6142..f18388c 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -484,7 +484,7 @@
+ + + +

+ +

+

+ define(\'WEBDECOY_DISABLE\', true);' + ); + ?> +

+
@@ -523,7 +547,7 @@

diff --git a/includes/class-webdecoy-activator.php b/includes/class-webdecoy-activator.php index c8d2aa8..09445e6 100644 --- a/includes/class-webdecoy-activator.php +++ b/includes/class-webdecoy-activator.php @@ -160,8 +160,12 @@ private static function set_default_options(): void 'custom_allowlist' => [], // Blocking Settings + // Monitor mode on by default: detect and log everything, block nothing, + // until the owner has seen what enforcement would do. Must match the + // defaults in WebDecoy_Plugin::load_options(). + 'monitor_mode' => true, 'block_action' => 'block', - 'block_duration' => 24, + 'block_duration' => 1, 'show_block_page' => true, 'block_page_message' => 'Access to this site has been restricted.', diff --git a/includes/class-webdecoy-woocommerce.php b/includes/class-webdecoy-woocommerce.php index c3709c5..18d22a8 100644 --- a/includes/class-webdecoy-woocommerce.php +++ b/includes/class-webdecoy-woocommerce.php @@ -58,11 +58,21 @@ public function check_checkout(): void $ip = $this->get_client_ip(); - // Check if already blocked. Monitor mode, WEBDECOY_DISABLE and an - // unconfigured proxy all mean "watch, don't act" — and that has to hold on - // the checkout path too, or the admin notice promising nothing is blocked - // is a lie to a merchant losing orders. - if ($this->blocker->is_blocked($ip) && !$this->suppressed()) { + // Monitor mode, WEBDECOY_DISABLE and an unconfigured proxy all mean "watch, + // don't act". That has to hold on the checkout path too — an error notice in + // woocommerce_checkout_process aborts the order, so an ungated refusal here + // means a store takes no money while the admin banner says nothing is blocked, + // and none of the three off switches help. Detection and logging stay + // unconditional; only the notice is withheld. + // + // The checkout path also never writes a sitewide IP block: behind an + // unconfigured proxy every shopper shares one address, so one false positive + // would 403 the whole store on every page. Refusing this order is already the + // proportionate response. Refs #52, #56. + $suppressed = $this->suppressed(); + + // Check if already blocked + if ($this->blocker->is_blocked($ip) && !$suppressed) { wc_add_notice( __('Your checkout has been blocked due to suspicious activity.', 'webdecoy'), 'error' @@ -70,31 +80,27 @@ public function check_checkout(): void return; } - // The checkout path stops the checkout and records the detection. It must - // never write a sitewide IP block: behind an unconfigured proxy every - // shopper shares one address, so a single false positive at checkout would - // 403 the whole store on every page. Refusing this order is already the - // proportionate response. Refs WebDecoy/wordpress-plugin#52. - // Check velocity if (!$this->check_velocity($ip)) { - wc_add_notice( - __('Too many checkout attempts. Please try again later.', 'webdecoy'), - 'error' - ); - $this->log_detection($ip, 'velocity_exceeded'); + if (!$suppressed) { + wc_add_notice( + __('Too many checkout attempts. Please try again later.', 'webdecoy'), + 'error' + ); + } return; } // Check for card testing patterns if ($this->detect_card_testing($ip)) { - wc_add_notice( - __('Suspicious checkout activity detected.', 'webdecoy'), - 'error' - ); - $this->log_detection($ip, 'card_testing'); + if (!$suppressed) { + wc_add_notice( + __('Suspicious checkout activity detected.', 'webdecoy'), + 'error' + ); + } return; } @@ -103,12 +109,13 @@ public function check_checkout(): void $result = $detector->analyze(); if ($result->shouldBlock($this->options['min_score_to_block'])) { - wc_add_notice( - __('Your checkout has been blocked due to suspicious activity.', 'webdecoy'), - 'error' - ); - $this->log_detection($ip, 'bot_detection', $result->getScore()); + if (!$suppressed) { + wc_add_notice( + __('Your checkout has been blocked due to suspicious activity.', 'webdecoy'), + 'error' + ); + } } } @@ -620,11 +627,17 @@ public function catch_coupon($data, string $code) $ip = $this->get_client_ip(); $this->log_detection($ip, 'honeytoken_coupon', 100); - if (($this->options['block_action'] ?? 'block') === 'block') { + // This is a real write to the sitewide block table, so it passes the same + // gate as every other enforcement action — it was firing in monitor mode + // under a banner reading "nothing is blocked" (#56). Rejecting the coupon + // is not enforcement: the code does not exist, and answering as though it + // does not is the deception working. + if (($this->options['block_action'] ?? 'block') === 'block' && !$this->suppressed()) { + $duration = (int) ($this->options['block_duration'] ?? 1); $this->blocker->block( $ip, 'Honeytoken coupon applied', - ($this->options['block_duration'] ?? 24) > 0 ? $this->options['block_duration'] : null + $duration > 0 ? $duration : null ); } @@ -703,9 +716,12 @@ function ($order, $request) { $woo = new WebDecoy_WooCommerce($options); $ip = $woo->get_client_ip_public(); - // Check if already blocked — suppressed states apply here too. + // Suppressed states apply here exactly as on the classic path (#56). + $suppressed = $woo->suppressed(); + + // Check if already blocked $blocker = new WebDecoy_Blocker(); - if ($blocker->is_blocked($ip) && !$woo->suppressed()) { + if ($blocker->is_blocked($ip) && !$suppressed) { throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_blocked', esc_html(__('Your checkout has been blocked due to suspicious activity.', 'webdecoy')), @@ -723,7 +739,9 @@ function ($order, $request) { // Check velocity if (!$woo->check_velocity_public($ip)) { $woo->log_detection_public($ip, 'velocity_exceeded'); - + if ($suppressed) { + return; + } throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_velocity', esc_html(__('Too many checkout attempts. Please try again later.', 'webdecoy')), @@ -734,7 +752,9 @@ function ($order, $request) { // Check for card testing patterns if ($woo->detect_card_testing_public($ip)) { $woo->log_detection_public($ip, 'card_testing'); - + if ($suppressed) { + return; + } throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'webdecoy_carding', esc_html(__('Suspicious checkout activity detected.', 'webdecoy')), diff --git a/webdecoy.php b/webdecoy.php index 21fdb5a..e164938 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -462,8 +462,28 @@ public function maybe_upgrade(): void // deliberate 24, and on a safety release the shorter block is the safer of // the two mistakes. $saved = get_option('webdecoy_options', []); - if (is_array($saved) && isset($saved['block_duration']) && (int) $saved['block_duration'] === 24) { + if (!is_array($saved)) { + $saved = []; + } + $dirty = false; + + // 2.3.2: PERSIST the new monitor_mode default. load_options() merges it in at + // runtime, but the settings form reads the stored array directly — without this + // the checkbox renders unchecked while the plugin is in monitor mode, and + // because an unchecked box submits nothing and sanitize_options() rebuilds the + // array from scratch, the next save of ANY setting writes false and silently + // turns full enforcement on. + if (!array_key_exists('monitor_mode', $saved)) { + $saved['monitor_mode'] = true; + $dirty = true; + } + + if (isset($saved['block_duration']) && (int) $saved['block_duration'] === 24) { $saved['block_duration'] = 1; + $dirty = true; + } + + if ($dirty) { $this->update_options_raw($saved); $this->load_options(); } @@ -1679,6 +1699,19 @@ private function handle_blocking(\WebDecoy\DetectionResult $result, string $ip): return; } + // The challenge page is a 403 with an interstitial. It is enforcement, so + // it passes the same gate as a block. Checked AFTER the verified-cookie + // test so a visitor who already solved it is not counted as withheld. + $suppression = $this->suppression_reason(); + if ($suppression !== '') { + do_action('webdecoy_enforcement_suppressed', $ip, $suppression, null); + $this->record_suppressed_action( + $suppression, + 'Challenge (score: ' . $result->getScore() . ')' + ); + return; + } + // Serve challenge page $this->serve_challenge_page($ip); return; @@ -1842,11 +1875,16 @@ public function filter_comment(array $commentdata): array $result = $detector->analyze(); if ($result->shouldBlock($this->options['min_score_to_block'])) { - wp_die( - esc_html__('Your comment has been blocked due to suspicious activity.', 'webdecoy'), - esc_html__('Comment Blocked', 'webdecoy'), - ['response' => 403, 'back_link' => true] - ); + // Reachable at default sensitivity and serves a literal 403 — same gate. + $suppression = $this->suppression_reason(); + if ($suppression === '') { + wp_die( + esc_html__('Your comment has been blocked due to suspicious activity.', 'webdecoy'), + esc_html__('Comment Blocked', 'webdecoy'), + ['response' => 403, 'back_link' => true] + ); + } + $this->record_suppressed_action($suppression, 'Comment refused: score ' . $result->getScore()); } return $commentdata; @@ -1865,6 +1903,13 @@ public function filter_comment(array $commentdata): array */ public function check_canary_login($user, string $username, string $password) { + // The kill switch has to be unconditional to be a kill switch. A canary hit is + // zero-false-positive evidence, so it is still refused in monitor mode — but + // never when the owner has explicitly turned the plugin off in wp-config.php. + if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { + return $user; + } + if ($username === '' && $password === '') { return $user; } @@ -1924,6 +1969,14 @@ public function check_login($user, string $username, string $password) $result = $detector->analyze(); if ($result->shouldBlock($this->options['min_score_to_block'])) { + // Refusing a login is enforcement, and it is the one refusal a locked-out + // owner cannot work around — so it must honour WEBDECOY_DISABLE and + // monitor mode like every other action. + $suppression = $this->suppression_reason(); + if ($suppression !== '') { + $this->record_suppressed_action($suppression, 'Login refused: score ' . $result->getScore()); + return $user; + } return new \WP_Error( 'webdecoy_blocked', __('Login blocked due to suspicious activity.', 'webdecoy') @@ -1948,6 +2001,11 @@ public function check_registration(string $sanitized_user_login, string $user_em $result = $detector->analyze(); if ($result->shouldBlock($this->options['min_score_to_block'])) { + $suppression = $this->suppression_reason(); + if ($suppression !== '') { + $this->record_suppressed_action($suppression, 'Registration refused: score ' . $result->getScore()); + return; + } $errors->add( 'webdecoy_blocked', __('Registration blocked due to suspicious activity.', 'webdecoy')