diff --git a/cdn-files/plugin-info.json b/cdn-files/plugin-info.json index 1efd5dc..4530fa2 100644 --- a/cdn-files/plugin-info.json +++ b/cdn-files/plugin-info.json @@ -1,17 +1,17 @@ { "name": "WebDecoy Bot Detection", "slug": "webdecoy", - "version": "2.10.4", + "version": "2.10.5", "author": "WebDecoy", "author_profile": "https://webdecoy.com", "requires": "6.1", "tested": "7.1", "requires_php": "7.4", - "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.10.4.zip", + "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.10.5.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

", "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
", - "changelog": "

2.10.4

2.10.3

2.10.2

2.10.1

2.10.0

2.9.1

2.9.0

2.3.1

2.3.0

2.1.0

2.0.0

1.3.0

", + "changelog": "

2.10.5

2.10.4

2.10.3

2.10.2

2.10.1

2.10.0

2.9.1

2.9.0

2.3.1

2.3.0

2.1.0

2.0.0

1.3.0

", "faq": "

Does WebDecoy slow down my site?

No. WebDecoy adds negligible latency; tripwire checks are a fast path lookup and clearance minting is idle-deferred.

Will it block search engines?

No. WebDecoy automatically allows 60+ known good bots including all major search engines, and tripwires only fire on hidden paths no legitimate crawler follows.

" }, "icons": { diff --git a/changelog.txt b/changelog.txt index 3c5b08c..64c769d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,9 @@ *** WebDecoy Bot Detection Changelog *** += 2.10.5 - 2026-10-04 = +* Fixed: maybe_flag_proxy() flagged an unconfigured proxy whenever an admin request carried any forwarding header. Hosts that rewrite REMOTE_ADDR to the visitor (WordPress.com, nginx real_ip, mod_remoteip) still forward X-Forwarded-For, so the notice showed on nearly every install and enforcement was suppressed with no cause. It now flags only when REMOTE_ADDR is absent from every forwarded address (WebDecoy_Blocker::unresolved_forwarding_header()); stale flags clear on the next admin page load. +* Fixed: the state notices linked to admin.php?page=webdecoy-settings, which is not a registered page; the settings slug is webdecoy. The proxy button is also anchored to the trusted-proxies field. + = 2.10.4 - 2026-10-04 = * Security: BotDetector::analyze(), given signals without ip_address, resolved the client IP from CF-Connecting-IP or the leftmost X-Forwarded-For with no trusted-proxy check, so a client chose the address its claimed good bot was reverse-DNS verified against. It now keeps a valid supplied ip_address and otherwise uses SignalCollector::getIP(), the trusted-proxy-aware resolver; the second resolver is removed (#85). * Fixed: WebDecoy_Detector, used by WooCommerce checkout, built its BotDetector without the configured trusted proxies, so behind Cloudflare it verified good bots against the edge address. It now uses webdecoy_plugin_trusted_proxies(). diff --git a/includes/class-webdecoy-blocker.php b/includes/class-webdecoy-blocker.php index 6b37c8c..4773065 100644 --- a/includes/class-webdecoy-blocker.php +++ b/includes/class-webdecoy-blocker.php @@ -209,6 +209,89 @@ public static function forwarding_header_seen(): string return ''; } + /** + * Like forwarding_header_seen(), but only for a header the server has NOT + * already resolved. Most managed hosts (WordPress.com, nginx real_ip, + * Apache mod_remoteip) rewrite REMOTE_ADDR to the visitor and still pass the + * forwarding headers along. In that case REMOTE_ADDR appears among the + * forwarded addresses, every visitor already has their own address, and there + * is nothing to fix. Only when REMOTE_ADDR is absent from every forwarded + * value is it the proxy rather than the visitor. + * + * Same restriction as forwarding_header_seen(): admin-side detection only. + */ + public static function unresolved_forwarding_header(): string + { + $seen = self::forwarding_header_seen(); + if ($seen === '') { + return ''; + } + + $remote = isset($_SERVER['REMOTE_ADDR']) + ? self::canonical_ip(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) + : ''; + if ($remote === '') { + return $seen; + } + + foreach (array_keys(self::FORWARDING_HEADERS) as $key) { + if (empty($_SERVER[$key])) { + continue; + } + $value = sanitize_text_field(wp_unslash($_SERVER[$key])); + foreach (self::forwarded_addresses($value) as $addr) { + if ($addr === $remote) { + return ''; + } + } + } + + return $seen; + } + + /** + * Every IP address in a forwarding header value, canonicalised. Handles the + * comma-separated X-Forwarded-For form and RFC 7239 `Forwarded: for=...`. + * + * @return string[] + */ + private static function forwarded_addresses(string $value): array + { + $out = []; + foreach (preg_split('/[,;]/', $value) ?: [] as $part) { + $part = trim($part); + if (stripos($part, 'for=') === 0) { + $part = substr($part, 4); + } elseif (strpos($part, '=') !== false) { + continue; // by=, proto=, host= + } + $ip = self::canonical_ip($part); + if ($ip !== '') { + $out[] = $ip; + } + } + return $out; + } + + /** + * Normalise an address token (quotes, [v6]:port, v4:port) to inet_ntop form, + * or '' when it is not an IP. + */ + private static function canonical_ip(string $token): string + { + $token = trim($token, " \t\""); + if (preg_match('/^\[([^\]]+)\](?::\d+)?$/', $token, $m)) { + $token = $m[1]; + } elseif (preg_match('/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/', $token, $m)) { + $token = $m[1]; + } + if (!filter_var($token, FILTER_VALIDATE_IP)) { + return ''; + } + $packed = inet_pton($token); + return $packed === false ? '' : (string) inet_ntop($packed); + } + /** * Record a refused block so it is visible rather than silent, and fire a hook. * diff --git a/readme.txt b/readme.txt index 2a74fc2..d6b438e 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Donate link: https://webdecoy.com Tags: bot detection, security, spam protection, woocommerce, ai bots Requires at least: 6.1 Tested up to: 7.1 -Stable tag: 2.10.4 +Stable tag: 2.10.5 Requires PHP: 7.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -285,6 +285,10 @@ The bundled good-bot list (sdk/src/GoodBotList.php) stores a documentation URL f == Changelog == += 2.10.5 = +* Fixed: the warning that WebDecoy is not blocking because the site is behind an unconfigured proxy no longer appears on hosts that already pass the visitor's real address to WordPress, such as WordPress.com. On those sites blocking was being withheld for no reason. +* Fixed: the warning's "Configure trusted proxies" button, and the monitor-mode "Review and turn on blocking" button, led to a "not allowed to access this page" error. They now open the settings page. + = 2.10.4 = * Security: good bots such as Googlebot are now verified against the visitor's real address. A visitor could previously choose the address that was checked by sending a forged forwarding header, and on sites behind Cloudflare, WooCommerce checkout checked Cloudflare's address instead of the visitor's. * Added: the WebDecoy Cloud tab shows whether Slack and webhook alerts are on for your plan, and where to configure them. @@ -469,6 +473,9 @@ Safety release. Please update. This version deliberately makes the plugin do les == Upgrade Notice == += 2.10.5 = +Fixes a false "behind a proxy" warning that switched off blocking on many managed hosts, and the broken button in that warning. + = 2.10.4 = Security fix: good bots are verified against the visitor's real address, not one a forged header supplies. Recommended upgrade. diff --git a/tests/ProxyDetectionTest.php b/tests/ProxyDetectionTest.php new file mode 100644 index 0000000..ff7544c --- /dev/null +++ b/tests/ProxyDetectionTest.php @@ -0,0 +1,53 @@ + [['REMOTE_ADDR' => '203.0.113.5'], ''], + 'host resolved XFF' => [['REMOTE_ADDR' => '203.0.113.5', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5'], ''], + 'host resolved XFF chain' => [['REMOTE_ADDR' => '203.0.113.5', 'HTTP_X_FORWARDED_FOR' => '198.51.100.1, 203.0.113.5'], ''], + 'host resolved CF' => [['REMOTE_ADDR' => '2001:db8::1', 'HTTP_CF_CONNECTING_IP' => '2001:DB8:0::1'], ''], + 'host resolved Forwarded' => [['REMOTE_ADDR' => '2001:db8::1', 'HTTP_FORWARDED' => 'for="[2001:db8::1]:4711";proto=https'], ''], + 'host resolved v4:port' => [['REMOTE_ADDR' => '203.0.113.5', 'HTTP_FORWARDED' => 'for=203.0.113.5:443'], ''], + 'unresolved XFF' => [['REMOTE_ADDR' => '10.0.0.2', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5'], 'X-Forwarded-For'], + 'unresolved CF' => [['REMOTE_ADDR' => '172.70.1.1', 'HTTP_CF_CONNECTING_IP' => '203.0.113.5', 'HTTP_X_FORWARDED_FOR' => '203.0.113.5'], 'CF-Connecting-IP'], + 'garbage header' => [['REMOTE_ADDR' => '10.0.0.2', 'HTTP_X_FORWARDED_FOR' => 'unknown'], 'X-Forwarded-For'], + ]; + try { + foreach ($cases as $name => [$server, $expected]) { + $_SERVER = $server; + TestRunner::assertSame($expected, WebDecoy_Blocker::unresolved_forwarding_header(), $name); + } + } finally { + $_SERVER = $saved; + } +}); diff --git a/webdecoy.php b/webdecoy.php index e22abdc..dabac6e 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.10.4 + * Version: 2.10.5 * 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.10.4'); +define('WEBDECOY_VERSION', '2.10.5'); define('WEBDECOY_PLUGIN_FILE', __FILE__); define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__)); @@ -418,13 +418,14 @@ public function maybe_flag_proxy(): void return; } - $seen = WebDecoy_Blocker::forwarding_header_seen(); + $seen = WebDecoy_Blocker::unresolved_forwarding_header(); $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. + // The proxy is gone, the host already resolves REMOTE_ADDR to the + // visitor, or the admin is reaching the origin directly. delete_option('webdecoy_proxy_detected'); } } @@ -522,7 +523,7 @@ public function render_state_notices(): void return; } - $settings_url = admin_url('admin.php?page=webdecoy-settings'); + $settings_url = admin_url('admin.php?page=webdecoy'); if (defined('WEBDECOY_DISABLE') && WEBDECOY_DISABLE) { printf( @@ -554,7 +555,7 @@ public function render_state_notices(): void ['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_url($settings_url . '#webdecoy_trusted_proxies'), esc_html__('Configure trusted proxies', 'webdecoy') ); return;